Webhooks are how you avoid polling. When a render reaches a terminal state — completed or failed — Kodisc POSTs a signed JSON payload to a URL you control.
When webhooks fire
A delivery is attempted exactly once per terminal state transition for jobs that have a webhookUrl. Two ways to set one:
- Default for the key: configure a
webhookUrl on the API key in the developer dashboard. Every job enqueued with that key inherits it.
- Per request: pass
webhookUrl in the body of POST /api/v2/render to override the key’s default for that single job. Pass null to opt the job out of webhooks entirely.
If neither is set, no webhook is delivered — you’re expected to poll.
Payload
The body is JSON. Field shapes mirror the GET /api/v2/render/{jobId} response.
{
"jobId": "clx9f0a1b0000abcd1234efgh",
"endpoint": "render",
"status": "completed",
"createdAt": "2026-04-28T17:14:02.118Z",
"completedAt": "2026-04-28T17:14:20.539Z",
"durationMs": 18420,
"creditsCost": 42,
"metadata": { "projectId": "proj_42" },
"result": {
"video": "https://cdn.kodisc.com/r/clx9f0a1b0000abcd1234efgh/video.mp4",
"thumbnail": "https://cdn.kodisc.com/r/clx9f0a1b0000abcd1234efgh/thumb.jpg",
"captions": "https://cdn.kodisc.com/r/clx9f0a1b0000abcd1234efgh/captions.vtt"
},
"error": null
}
For failed jobs, status is "failed", result is null, and error carries a human-readable message.
Each delivery includes:
| Header | Value |
|---|
content-type | application/json |
kodisc-event | completed or failed |
kodisc-signature | t=<unix-seconds>,v1=<hex hmac> — see below |
Verifying signatures
The signature lives in kodisc-signature and looks like t=1714326842,v1=4f8a…. To verify:
- Split the header on
, and parse out t (timestamp) and v1 (HMAC). When splitting each key=value pair, only split on the first = — values like base64-encoded HMACs may contain = padding.
- Build the signed string as
${t}.${rawBody} — the literal request body, byte-for-byte, before any JSON parsing.
- Compute
HMAC-SHA256(signed_string, webhook_secret) and hex-encode it.
- Compare against
v1 using a constant-time equality check.
- Always verify the timestamp is within 5 minutes of now to prevent replay of captured deliveries. Without this check, an attacker who once captures a valid signed request can replay it forever.
Reject the request if anything doesn’t match.
import crypto from "node:crypto";
const TOLERANCE = 5 * 60; // 5 minutes
export function verifyKodiscSignature(
rawBody: string,
header: string | null,
secret: string,
): boolean {
if (!header) return false;
const parts = Object.fromEntries(
header.split(",").map((p) => {
// Only split on the first `=` so base64-padded values survive intact.
const [k, ...rest] = p.trim().split("=");
const v = rest.join("=");
return [k, v] as [string, string];
}),
);
const timestamp = parts.t;
const received = parts.v1;
if (!timestamp || !received) return false;
// Reject deliveries whose timestamp is outside the freshness window —
// prevents replay of captured signed requests.
if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > TOLERANCE) {
return false;
}
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(received, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Verify against the raw request body, not a re-serialized version. Frameworks like Express or FastAPI may JSON-parse the body and lose key ordering — capture the bytes before that happens.
Retries
Kodisc treats a delivery as successful when your endpoint returns a 2xx within 10 seconds. Otherwise, it retries up to 3 more times with these delays from the original attempt:
| Attempt | Delay before sending |
|---|
| 1 | immediate |
| 2 | 30 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
After the fourth attempt, Kodisc stops trying. The job’s last delivery status (HTTP code, or 0 if the request never connected) and number of attempts are visible in the dashboard, so you can replay manually if needed.
Design your handler to be idempotent — even though Kodisc doesn’t intentionally double-deliver, network edges happen. Treat jobId as the dedupe key.
Recommended response
Return 2xx as soon as you’ve persisted the event. Do the heavy work (downloading the video, updating your DB, notifying users) asynchronously. Slow handlers eat into your 10-second window and can trigger spurious retries.
Testing locally
Kodisc requires webhook URLs to start with https://, and deliveries are made from Kodisc’s servers — so http://localhost won’t work. The solution is a tunnel: a tool that gives your local server a public HTTPS URL that Kodisc can reach.
Option 1: ngrok
The most widely used option. Install ngrok, then run:
ngrok prints a URL like https://abc123.ngrok-free.app. Use that as your webhook URL when calling the API or configuring your key.
Option 2: Cloudflare Tunnel
No account required for quick tunnels. Install cloudflared, then run:
cloudflared tunnel --url http://localhost:3000
Cloudflare prints a https:// URL you can use immediately.
Option 3: VS Code port forwarding
If you’re using VS Code with a GitHub account, open the Ports panel, right-click your local port, and select Make Public. VS Code copies an https:// forwarding URL to your clipboard.
Once your tunnel is running, pass its URL as webhookUrl in your render request:
curl -X POST https://api.kodisc.com/api/v2/render \
-H "Authorization: Bearer <key>" \
-H "Content-Type: application/json" \
-d '{
"code": "...",
"className": "MyScene",
"webhookUrl": "https://abc123.ngrok-free.app/webhook"
}'
Your local handler will receive the delivery as if it were in production.