Webhooks
Instead of polling the status, the API can knock on your URL itself when something happens to an order.
Registration
POST /webhooks:
- curl
- Python
- JavaScript
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"}'
import requests
resp = requests.post(
"https://api.piratepress.fun/public/v1/webhooks",
headers={"X-API-Key": "pp_..."},
json={"url": "https://example.com/piratepress/webhook"},
)
resp.raise_for_status()
webhook = resp.json()
print(webhook["id"], webhook["secret"]) # secret is shown once
const resp = await fetch("https://api.piratepress.fun/public/v1/webhooks", {
method: "POST",
headers: {
"X-API-Key": "pp_...",
"Content-Type": "application/json",
},
body: JSON.stringify({ url: "https://example.com/piratepress/webhook" }),
});
if (!resp.ok) throw new Error(`API ${resp.status}: ${await resp.text()}`);
const webhook = await resp.json();
console.log(webhook.id, webhook.secret); // secret is shown once
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, the body carriesresult_url;video.error— generation failed (doubloons already refunded);video.awaiting_review— director mode: the script awaits your decision (POST /videos/{id}/review).
The event body is the same JSON that GET /videos/{id} returns; derive
the event type from the status field (done / error /
awaiting_review).
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.
- Python
- JavaScript
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["status"] == "done":
download(event["result_url"]) # your logic — in the background
return {"ok": True}
import crypto from "node:crypto";
import express from "express";
const app = express();
const WEBHOOK_SECRET = process.env.PP_WEBHOOK_SECRET; // whs_...
app.post(
"/piratepress/webhook",
// raw body is a must — express.json() won't do here
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.get("X-PiratePress-Signature") ?? "";
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(req.body)
.digest("hex");
const a = Buffer.from(signature, "utf8");
const b = Buffer.from(expected, "utf8");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
if (event.status === "done") {
// your logic — in the background
}
res.json({ ok: true });
},
);
Delivery and retries
- Answer
2xxfast (a couple of seconds): put mp4 downloads and other heavy work in your own queue. A timeout or non-2xxmeans a failed delivery. - Failed deliveries are retried: 5 attempts with exponential
backoff. After the fifth, the event is not lost — you can always
poll the order via
GET /videos/{id}. - Duplicate delivery is possible (a retry after a timeout when you
actually answered
200). Keep the handler idempotent: the idempotency key is the orderid+status. - The URL must be public HTTPS — a self-signed certificate won't fly; use a tunnel (ngrok etc.) for local debugging.