Webhooks
Get notified when a render completes or fails.
Why webhooks
Renders are async. Instead of polling GET /api/v1/status/{jobId}, register a webhook and Reeloop POSTs the result to your endpoint.
Events
One delivery fires per finished job, distinguished by the type field:
render.completed- the video is ready;dataincludesvideoUrl.render.failed- the render failed; credits are refunded automatically.
Payload
Every delivery carries a stable event id, a type and a timestamp, with the job fields nested under data:
{
"id": "evt_7c9e2a1d4f8a4c2b9e1d3a5b6c7d8e9f_done",
"type": "render.completed",
"createdAt": "2026-08-04T12:00:00.000Z",
"data": {
"jobId": "7c9e2a1d-β¦",
"jobStatus": "completed",
"renderStatus": "done",
"videoUrl": "https://β¦/media/β¦.mp4"
}
}id- unique event id,evt_<jobId without dashes>_<renderStatus>. Store it and discard duplicates.type- the machine-stable discriminator:render.completedorrender.failed.createdAt- ISO-8601 delivery time. Enforce your own tolerance window (e.g. 5 minutes) and reject older payloads to defeat replay.data.jobStatus- the polling-contract job status. It always ends atcompleted(a failed render refunds and still completes) - it is not the outcome.data.renderStatus- the actual outcome:doneorfailed, exactly the same valuesGET /api/v1/status/{jobId}returns.data.videoUrlis present only on success.
This mirrors GET /api/v1/status/{jobId} exactly, so switching between webhook and polling needs no re-interpretation.
Headers
| Header | Value |
|---|---|
X-Reeloop-Signature | sha256=<hex> - hex HMAC-SHA256 of the raw request body, keyed with your webhook secret |
X-Reeloop-Event-Id | The same event id that appears in the payload |
The string to sign is the raw body exactly as received. Do not parse and re-serialize the JSON before verifying - key order or whitespace differences will break the signature.
Verifying
Next.js (App Router):
import { createHmac, timingSafeEqual } from "crypto";export async function POST(req: Request) { const raw = await req.text(); // raw body - do not JSON.parse first const sig = req.headers.get("x-reeloop-signature") ?? ""; const expected = "sha256=" + createHmac("sha256", process.env.WEBHOOK_SECRET!).update(raw).digest("hex"); const ok = sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected)); if (!ok) return new Response("bad signature", { status: 401 }); const event = JSON.parse(raw); // dedupe on event.id, then branch on event.type ("render.completed" | "render.failed") // - or read event.data.renderStatus ("done" | "failed"), same values as the polling endpoint return new Response("ok"); } ```
Express (needs the raw body middleware):
app.post("/webhooks/reeloop",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.get("x-reeloop-signature") ?? "";
const expected = "sha256=" + crypto.createHmac("sha256", process.env.WEBHOOK_SECRET)
.update(req.body).digest("hex"); // req.body is a Buffer here
if (sig.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString());
res.sendStatus(200);
});Python (Flask):
import hmac, hashlib, os@app.post("/webhooks/reeloop") def reeloop_webhook(): raw = request.get_data() # raw bytes sig = request.headers.get("X-Reeloop-Signature", "") expected = "sha256=" + hmac.new( os.environ["WEBHOOK_SECRET"].encode(), raw, hashlib.sha256 ).hexdigest() if not hmac.compare_digest(sig, expected): return ("bad signature", 401) event = json.loads(raw) return ("ok", 200) ```
The SDK's verifyWebhookSignature(rawBody, signatureHeader, secret) does the same check with constant-time comparison and returns the parsed payload, or null on a bad signature.
Retries & replay
Respond with a 2xx quickly (under 10 seconds - that's the delivery timeout). A non-2xx response or a timeout schedules an automatic retry; you don't need to poll to recover a transient outage.
Retry schedule - initial attempt, then:
+1 minute β +5 minutes β +30 minutes β +2 hours β +8 hours β +24 hours
Each delivery is recorded with its status, attempt count, and next scheduled attempt:
| Status | Meaning |
|---|---|
pending | First attempt in flight |
delivered | Your endpoint returned 2xx |
retrying | Scheduled for an automatic retry |
disabled | Dead-lettered - every retry failed; we emailed you once |
After the full schedule (~35h) the delivery is dead-lettered and we notify the account owner by email. You can replay any delivery from your dashboard - a replayed (or retried) delivery keeps its original event id, so dedupe on it. Polling GET /api/v1/status/{jobId} stays available as a belt-and-braces fallback.