Вебхуки
Вместо поллинга статуса API может сам постучаться на твой URL, когда с заказом что-то случилось.
Регистрация
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 показывается один раз
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 показывается один раз
Ответ 201:
{
"id": "wh_...",
"url": "https://example.com/piratepress/webhook",
"active": true,
"created_at": "2026-09-08T12:00:00Z",
"secret": "whs_..."
}
Один эндпоинт на ключ: повторный POST /webhooks заменяет URL и
перевыпускает секрет. secret показывается один раз — сохрани его,
им проверяется подпись.
Управление: GET /webhooks — список, DELETE /webhooks/{id} — удалить
(204).
События
video.done— ролик готов, вdataестьresult_url;video.error— генерация упала без списания дублонов (триал или заказ, оплаченный извне);video.refunded— генерация упала, дублоны за заказ уже возвращены на баланс;video.awaiting_review— режиссёрский режим: сценарий ждёт решения (POST /videos/{id}/review);clip.done— нарезка готова, вdataклипы с подписанными ссылками иzip_url;clip.error— нарезка упала, оценка возвращена на баланс.
Видео-события приходят для заказов с каналом создания api/agent
(созданных через публичный API), события нарезок — тоже только для
заказов с origin api/agent.
Формат тела
Конверт — {"event", "data"}: тип события в поле event, а data —
тот же JSON, что отдаёт GET /videos/{id} (для нарезок —
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"
}
}
Тип события определяй по полю event, а не по data.status: одна и та
же ручка статуса обслуживает все статусы заказа, а событие приходит на
конкретный переход.
Проверка подписи
Каждый запрос подписан заголовком:
X-PiratePress-Signature: <hex HMAC-SHA256(secret, сырой body)>
Проверяй подпись до парсинга JSON и по сырым байтам тела — пере-serialизация JSON меняет байты и ломает подпись. Сравнение — только constant-time.
- Python
- JavaScript
import hashlib
import hmac
import json
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
WEBHOOK_SECRET = "whs_..." # из ответа на регистрацию вебхука
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() # сырые байты, не 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"]) # своя логика — в фоне/очереди
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",
// сырой body — обязательно, express.json() тут не подходит
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.event === "video.done") {
// event.data.result_url — твоя логика, в фоне/очереди
}
res.json({ ok: true });
},
);
Доставка и ретраи
- Отвечай
2xxбыстро (пара секунд): скачивание mp4 и прочую тяжесть клади в свою очередь. Таймаут или не-2xx= неуспешная доставка. - Неуспешная доставка ретраится: 5 попыток (паузы 10 с, 1 мин,
5 мин, 30 мин). После пятой событие не пропадает — заказ всегда можно
дополлить через
GET /videos/{id}илиGET /clips/{id}. - Повторная доставка возможна (ретрай после таймаута, когда ты на самом
деле ответил
200). Делай обработчик идемпотентным: ключ идемпотентности —event+data.id. - URL должен быть публичным HTTPS — самоподписанный сертификат не прокатит, для локальной отладки бери туннель (ngrok и т.п.).