> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kodisc.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Get notified the moment a render finishes, with verifiable signatures.

Webhooks are how you avoid polling. When a render reaches a terminal state — `completed` or `failed` — Kodisc `POST`s 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](https://kodisc.com/developer/keys). 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}`](/api-reference/endpoint/get-render) response.

```json theme={"system"}
{
  "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.

## Headers

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:

1. 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.
2. Build the **signed string** as `${t}.${rawBody}` — the literal request body, byte-for-byte, before any JSON parsing.
3. Compute `HMAC-SHA256(signed_string, webhook_secret)` and hex-encode it.
4. Compare against `v1` using a constant-time equality check.
5. **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.

<CodeGroup>
  ```ts node theme={"system"}
  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);
  }
  ```

  ```python python theme={"system"}
  import hmac
  import time
  from hashlib import sha256

  TOLERANCE = 5 * 60  # 5 minutes

  def verify_kodisc_signature(raw_body: bytes, header: str | None, secret: str) -> bool:
      if not header:
          return False

      parts = dict(p.strip().split("=", 1) for p in header.split(","))
      timestamp = parts.get("t")
      received = parts.get("v1")
      if not timestamp or not received:
          return False

      # Reject deliveries whose timestamp is outside the freshness window —
      # prevents replay of captured signed requests.
      if abs(int(time.time()) - int(timestamp)) > TOLERANCE:
          return False

      signed = f"{timestamp}.{raw_body.decode()}".encode()
      expected = hmac.new(secret.encode(), signed, sha256).hexdigest()
      return hmac.compare_digest(expected, received)
  ```
</CodeGroup>

<Warning>
  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.
</Warning>

## 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](https://ngrok.com/download), then run:

```bash theme={"system"}
ngrok http 3000
```

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](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/), then run:

```bash theme={"system"}
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:

```bash theme={"system"}
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.
