Skip to content

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; data includes videoUrl.
  • 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.completed or render.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 at completed (a failed render refunds and still completes) - it is not the outcome.
  • data.renderStatus - the actual outcome: done or failed, exactly the same values GET /api/v1/status/{jobId} returns. data.videoUrl is present only on success.

This mirrors GET /api/v1/status/{jobId} exactly, so switching between webhook and polling needs no re-interpretation.

Headers

HeaderValue
X-Reeloop-Signaturesha256=<hex> - hex HMAC-SHA256 of the raw request body, keyed with your webhook secret
X-Reeloop-Event-IdThe 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:

StatusMeaning
pendingFirst attempt in flight
deliveredYour endpoint returned 2xx
retryingScheduled for an automatic retry
disabledDead-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.

Start building

Create an API key in Settings β†’ API.

Start for free β†’

1 free video Β· no credit card