Skip to main content

Webhooks

Instead of polling the status, the API can knock on your URL itself when something happens to an order.

Registration​

POST /webhooks:

curl -X POST https://api.piratepress.fun/public/v1/webhooks \
-H "X-API-Key: pp_..." \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/piratepress/webhook"}'

Response 201:

{
"id": "wh_...",
"url": "https://example.com/piratepress/webhook",
"active": true,
"created_at": "2026-09-08T12:00:00Z",
"secret": "whs_..."
}

One endpoint per key: a repeated POST /webhooks replaces the URL and reissues the secret. The secret is shown once — save it; it's what verifies the signature.

Management: GET /webhooks — list, DELETE /webhooks/{id} — remove (204).

Events​

  • video.done — the video is ready, data carries result_url;
  • video.error — generation failed with no doubloon charge (trial, or an externally paid order);
  • video.refunded — generation failed, the doubloons were refunded to the balance automatically;
  • video.awaiting_review — director mode: the script awaits your decision (POST /videos/{id}/review);
  • clip.done — a clip order is ready, data carries the clips with signed URLs and zip_url;
  • clip.error — a clip order failed, the estimate was refunded.

Video events fire for orders created via the public API (origin api/agent); clip events likewise only for clip orders with origin api/agent.

Body format​

The envelope is {"event", "data"}: the event type goes in event, and data is the same JSON that GET /videos/{id} returns (for clips — GET /clips/{id}):

{
"event": "video.done",
"data": {
"id": "gen_a1b2c3",
"status": "done",
"result_url": "https://api.piratepress.fun/public/v1/files/eyJ...",
"cost": 100,
"created_at": "2026-10-19T12:00:00Z"
}
}

Branch on the event field, not on data.status: the status endpoint serves every status of an order, while an event marks a specific transition.

Signature verification​

Every request is signed with the header:

X-PiratePress-Signature: <hex HMAC-SHA256(secret, raw body)>

Verify the signature before parsing the JSON and against the raw body bytes — re-serializing JSON changes the bytes and breaks the signature. Compare in constant time only.

import hashlib
import hmac
import json

from fastapi import FastAPI, HTTPException, Request

app = FastAPI()
WEBHOOK_SECRET = "whs_..." # from the webhook registration response


def verify_signature(secret: str, body: bytes, signature: str) -> bool:
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)


@app.post("/piratepress/webhook")
async def piratepress_webhook(request: Request) -> dict:
body = await request.body() # raw bytes, not request.json()!
signature = request.headers.get("X-PiratePress-Signature", "")
if not verify_signature(WEBHOOK_SECRET, body, signature):
raise HTTPException(status_code=401)

event = json.loads(body)
if event["event"] == "video.done":
download(event["data"]["result_url"]) # your logic — in the background
return {"ok": True}

Delivery and retries​

  • Answer 2xx fast (a couple of seconds): put mp4 downloads and other heavy work in your own queue. A timeout or non-2xx means a failed delivery.
  • Failed deliveries are retried: 5 attempts (delays 10 s, 1 min, 5 min, 30 min). After the fifth, the event is not lost — you can always poll the order via GET /videos/{id} or GET /clips/{id}.
  • Duplicate delivery is possible (a retry after a timeout when you actually answered 200). Keep the handler idempotent: the idempotency key is event + data.id.
  • The URL must be public HTTPS — a self-signed certificate won't fly; use a tunnel (ngrok etc.) for local debugging.