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.
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"
}| Header | Value |
|---|---|
| Reel-Signature | t= the Unix time we signed at, and v1= the hex HMAC-SHA256 signature |
| Reel-Job-Id | The job's id |
| Reel-Attempt | 1, 2 or 3 |
| Idempotency-Key | <job id>:<status>, the same on every attempt |
| User-Agent | reelsdk-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:
- Read the raw request body, before any JSON parsing.
- Split
Reel-Signatureon commas intotandv1. - Reject the delivery if
tis more than 300 seconds away from your clock. - Compute HMAC-SHA256 of
t, a dot and the raw body, keyed with your full secret, and compare the hex result tov1in 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.
| Attempt | Sent |
|---|---|
| 1 | As soon as the job succeeds or fails |
| 2 | 2 seconds after attempt 1 failed |
| 3 | 8 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
204right 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-Keyyou've already handled. - Handle both outcomes. Check
status: a failed job haserror.codeanderror.messageinstead ofoutputs. - 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 3000Last updated 26 October 2026