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

# Webhooks and signatures

> Get called once a generation finishes instead of polling, verify the Standard Webhooks signature with your webhook signing secret, and handle retries and duplicates.

Instead of polling `GET /generations/{id}`, you can pass `webhook_url` when you create a generation. Codzen then sends the finished generation to that URL in a `POST` request.

```json theme={null}
{
  "model": "codzen-play",
  "input": { "prompt": "A paper boat drifts down a rain-filled gutter" },
  "webhook_url": "https://example.com/webhooks/codzen"
}
```

`webhook_url` must be an HTTPS URL. It is set per generation; there is no account-wide webhook to register.

## When webhooks are sent

A webhook is sent only once the generation reaches a terminal state: `succeeded` or `failed`. There is no webhook for `queued` or `processing`.

The request body is the finished generation, exactly as `GET /generations/{id}` returns it:

```json theme={null}
{
  "id": "gen_01JBXK2Y7P1N3R5T7V9X1Z3B5D",
  "model": "codzen-play",
  "status": "succeeded",
  "input": { "prompt": "A paper boat drifts down a rain-filled gutter" },
  "outputs": [{ "type": "video", "url": "https://files.codzen.ai/..." }],
  "cost": {
    "currency": "USD",
    "estimated": "0.50",
    "amount": "0.50",
    "usage": [{ "unit": "output_second", "quantity": 5, "unit_price": "0.10" }]
  },
  "error": null,
  "created_at": "2026-10-26T08:00:00Z",
  "started_at": "2026-10-26T08:00:03Z",
  "completed_at": "2026-10-26T08:01:10Z"
}
```

| Field | Description |
| - | - |
| `id` | ID of the generation (`gen_…`). |
| `model` | Slug of the model, as submitted. |
| `status` | `succeeded` or `failed`. |
| `input` | The input, normalized: input assets point at Codzen copies; other fields are as submitted. |
| `outputs` | Delivered outputs, each `{ type, url }`. Empty when the generation failed. |
| `cost` | The estimate and the final cost. See [Billing](https://docs.codzen.ai/guides/billing.md). |
| `error` | Why the generation failed, as an [error object](https://docs.codzen.ai/guides/errors.md); `null` when it succeeded. |
| `created_at`, `started_at`, `completed_at` | ISO 8601 times in UTC. `started_at` is `null` if it failed before processing started. |

The [webhook reference](https://docs.codzen.ai/api-reference/generation-completed.md) has the same payload as a schema.

## Delivery and retries

* **Answer with any 2xx status** to confirm the delivery. Any other status, a timeout or a connection error counts as a failure.
* **Failed deliveries are retried** with exponential backoff for 24 hours, then dropped. A failed delivery never changes the generation; you can still read it with `GET /generations/{id}`.
* **Delivery is at least once.** The same webhook can arrive more than once. Every retry carries the same `webhook-id` header, so store the IDs you have handled and skip a webhook whose ID you have already seen.
* **Order is not guaranteed** across generations. Use each generation's `id` and `status`, not arrival order.

Answer quickly: verify the signature, record the webhook, return `200`, and do slow work such as downloading outputs afterwards.

## Verify the signature

Webhooks follow the [Standard Webhooks](https://www.standardwebhooks.com/) specification. Every webhook carries three headers:

| Header | Value |
| - | - |
| `webhook-id` | The event ID; the same on every retry of a delivery. |
| `webhook-timestamp` | When this attempt was sent, in Unix seconds. |
| `webhook-signature` | One or more space-separated signatures, each `v1,<base64>`. |

Each signature is an HMAC-SHA256 of the string `<webhook-id>.<webhook-timestamp>.<body>`, keyed with your **webhook signing secret**. The secret looks like `whsec_<base64>`; the key is the base64-decoded part after `whsec_`. Your account has one webhook signing secret, which you can view and rotate in the console.

To verify a webhook:

1. Read the raw request body as bytes, before any JSON parsing. Re-serialized JSON will not match the signature.
2. Reject the webhook if `webhook-timestamp` is more than 5 minutes from your clock, so that a captured request cannot be replayed later.
3. Compute the HMAC-SHA256 of `<webhook-id>.<webhook-timestamp>.<body>` and base64-encode it.
4. Accept the webhook if any `v1` signature in `webhook-signature` matches, compared in constant time.

These functions do all four steps with no dependencies. Each returns the parsed generation, or throws (raises) if the webhook is not valid.

```javascript Node theme={null}
import crypto from 'node:crypto';

const TOLERANCE_SECONDS = 5 * 60;

/**
 * Verifies a Codzen webhook and returns the generation it carries.
 * rawBody: the request body as a string or Buffer, exactly as received.
 * headers: the request headers, with lowercase names.
 * secret: your webhook signing secret, `whsec_…`.
 */
function verifyWebhook(rawBody, headers, secret) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatures = headers['webhook-signature'];
  if (!id || !timestamp || !signatures) {
    throw new Error('Missing webhook headers');
  }

  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) {
    throw new Error('Webhook timestamp is outside the tolerance');
  }

  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  const expected = crypto
    .createHmac('sha256', key)
    .update(`${id}.${timestamp}.`)
    .update(rawBody)
    .digest();

  const valid = signatures.split(' ').some((signature) => {
    const [version, value] = signature.split(',');
    if (version !== 'v1' || !value) return false;
    const received = Buffer.from(value, 'base64');
    return (
      received.length === expected.length &&
      crypto.timingSafeEqual(received, expected)
    );
  });
  if (!valid) {
    throw new Error('Invalid webhook signature');
  }

  return JSON.parse(rawBody.toString());
}
```

```python Python theme={null}
import base64
import hashlib
import hmac
import json
import time

TOLERANCE_SECONDS = 5 * 60


def verify_webhook(raw_body: bytes, headers, secret: str):
    """Verifies a Codzen webhook and returns the generation it carries.

    raw_body: the request body as bytes, exactly as received.
    headers: the request headers (a dict with lowercase names, or any
        mapping with case-insensitive lookup, such as Flask's).
    secret: your webhook signing secret, "whsec_...".
    """
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not msg_id or not timestamp or not signatures:
        raise ValueError("Missing webhook headers")

    if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
        raise ValueError("Webhook timestamp is outside the tolerance")

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(
        hmac.new(key, signed, hashlib.sha256).digest()
    ).decode()

    for signature in signatures.split(" "):
        version, _, value = signature.partition(",")
        if version == "v1" and hmac.compare_digest(value, expected):
            return json.loads(raw_body)
    raise ValueError("Invalid webhook signature")
```

Use them in your webhook handler. Keep the secret in an environment variable, and make sure your framework hands you the raw body:

```javascript Express theme={null}
import express from 'express';

const app = express();

app.post(
  '/webhooks/codzen',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    let generation;
    try {
      generation = verifyWebhook(
        req.body,
        req.headers,
        process.env.CODZEN_WEBHOOK_SECRET
      );
    } catch {
      return res.sendStatus(400);
    }
    // Skip it if you have already handled req.headers['webhook-id'].
    console.log(generation.id, generation.status);
    res.sendStatus(200);
  }
);
```

```python Flask theme={null}
import os

from flask import Flask, request

app = Flask(__name__)


@app.post("/webhooks/codzen")
def codzen_webhook():
    try:
        generation = verify_webhook(
            request.get_data(),
            request.headers,
            os.environ["CODZEN_WEBHOOK_SECRET"],
        )
    except ValueError:
        return "", 400
    # Skip it if you have already handled request.headers["webhook-id"].
    print(generation["id"], generation["status"])
    return "", 200
```

> [!TIP]
>
> Any [Standard Webhooks library](https://github.com/standard-webhooks/standard-webhooks) verifies Codzen webhooks too: pass it your `whsec_…` secret, the raw body and the three headers.

## Rotate the webhook signing secret

You can rotate the webhook signing secret in the console at any time. Rotation creates a new secret right away, and the old one stays valid for 24 hours. During those 24 hours, every webhook's `webhook-signature` carries two signatures, one made with each secret, separated by a space.

To rotate without dropping webhooks:

1. Rotate the secret in the console and copy the new one.
2. Deploy the new secret to your webhook handler within 24 hours. Because each webhook is signed with both secrets meanwhile, the handler accepts webhooks with either the old or the new secret, and the examples above need no change.
3. After 24 hours, webhooks carry only the new signature.

> [!WARNING]
>
> If you think your webhook signing secret has leaked, rotate it and deploy the new one at once: the old one keeps signing webhooks for 24 hours.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.