Concepts

Webhooks

Add a webhook_url to any create request and we POST the finished job to it, signed with HMAC-SHA256. Each delivery gets up to 3 attempts of 15 seconds each, so you can skip polling altogether.

Webhooks are the best fit for shorts and long renders, and for keeping serverless functions short.

Signature
HMAC-SHA256
in the Reel-Signature header
Attempts
3
right away, then 2 s and 8 s later
Timeout
15 s
per attempt, any 2xx counts

Set up a webhook#

Add webhook_url to the body of any create call. With ?wait=0 the call returns 202 at once and the job arrives at your URL when it succeeds or fails.

curl -sS "https://api.reelsdk.com/v1/captions?wait=0" \
  -H "Authorization: Bearer $REELSDK_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "video_url": "https://example.com/clip.mp4",
    "webhook_url": "https://your-app.com/webhooks/reelsdk"
  }'

The webhook fires for every job that has one, even when the create call already returned the finished job, so one handler can process every result.

webhook_url must be a public http or https URL of up to 4,096 characters. localhost, private IP addresses and hostnames without a dot are refused with 400, such as webhook_url: must use a public hostname.

The delivery#

We send a POST with the job object as compact JSON on one line, formatted here. The body is the job itself, not wrapped in { "job": ... } like API responses.

Request to your server
POST /webhooks/reelsdk HTTP/1.1
Content-Type: application/json
User-Agent: reelsdk-webhooks/1
Reel-Signature: t=1792979460,v1=a0dabde37dd2a7ef60f77d01288dbce63f857a481657de9159de4b82355717a3
Reel-Job-Id: job_xeptoylkpl6ff9mecyan
Reel-Attempt: 1
Idempotency-Key: job_xeptoylkpl6ff9mecyan:succeeded

{
  "id": "job_xeptoylkpl6ff9mecyan",
  "op": "ffmpeg",
  "status": "succeeded",
  "stage": null,
  "created_at": "2026-10-26T01:50:57.370Z",
  "finished_at": "2026-10-26T01:51:00.259Z",
  "outputs": {
    "out.jpg": "https://api.reelsdk.com/f/out/job_xeptoylkpl6ff9mecyan/out.jpg"
  },
  "result": null,
  "duration_s": null,
  "error": null,
  "usage": {
    "output_seconds": 0,
    "audio_seconds": 0,
    "tts_characters": 0,
    "images": 0
  },
  "poll_url": "https://api.reelsdk.com/v1/jobs/job_xeptoylkpl6ff9mecyan"
}
HeaderValue
Reel-Signaturet= the Unix time we signed at, and v1= the hex HMAC-SHA256 signature
Reel-Job-IdThe job's id
Reel-Attempt1, 2 or 3
Idempotency-Key<job id>:<status>, the same on every attempt
User-Agentreelsdk-webhooks/1

Verify the signature#

Every API key comes with a webhook secret that starts with whsec_. Check each delivery with it before you trust the body:

  1. Read the raw request body, before any JSON parsing.
  2. Split Reel-Signature on commas into t and v1.
  3. Reject the delivery if t is more than 300 seconds away from your clock.
  4. Compute HMAC-SHA256 of t, a dot and the raw body, keyed with your full secret, and compare the hex result to v1 in constant time.
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";

const SECRET = process.env.REELSDK_WEBHOOK_SECRET;
const TOLERANCE_S = 300;

function verify(header, body) {
  const fields = header.split(",").map((part) => part.trim().split("="));
  const timestamp = Number(fields.find(([name]) => name === "t")?.[1]);
  const age = Math.abs(Date.now() / 1000 - timestamp);
  if (!Number.isInteger(timestamp) || age > TOLERANCE_S) return false;

  const expected = createHmac("sha256", SECRET)
    .update(`${timestamp}.`)
    .update(body)
    .digest();

  return fields.some(([name, value]) => {
    if (name !== "v1") return false;
    const given = Buffer.from(value ?? "", "hex");
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}

createServer((req, res) => {
  const chunks = [];
  req.on("data", (chunk) => chunks.push(chunk));
  req.on("end", () => {
    const body = Buffer.concat(chunks);
    if (!verify(req.headers["reel-signature"] ?? "", body)) {
      res.writeHead(400).end("invalid signature");
      return;
    }

    const job = JSON.parse(body.toString("utf8"));
    res.writeHead(204).end();
    console.log(job.id, job.status, job.outputs);
  });
}).listen(3000);

All three were tested against a real delivery. The Deno version uses Web Crypto, so the same verify works in Cloudflare Workers, Bun and Next.js route handlers.

Retries#

A delivery succeeds when your server answers with any 2xx status within 15 seconds. Any other status, a redirect, a timeout or a refused connection counts as a failure, and we try again.

AttemptSent
1As soon as the job succeeds or fails
22 seconds after attempt 1 failed
38 seconds after attempt 2 failed

After the third failure we stop. The finished job is still at its poll_url, so you can fetch any result a delivery missed.

Handling deliveries#

  • Answer first, work later. Return 204 right away and copy the video or update your database afterwards, so you stay inside the 15 seconds.
  • Expect duplicates. An attempt that timed out on our side may still have reached you, so skip a delivery whose Idempotency-Key you've already handled.
  • Handle both outcomes. Check status: a failed job has error.code and error.message instead of outputs.
  • Copy the files you keep. Output URLs expire after 7 days.

Testing locally#

Since webhook_url must be public, expose your local server with a tunnel and use the https URL it prints. A request inspector such as webhook.site shows you a real delivery with all its headers.

cloudflared tunnel --url http://localhost:3000

# or
ngrok http 3000

Last updated 26 October 2026