# Guide to OpenRouter Webhooks: Features and Best Practices

OpenRouter webhooks tell your application when a video generation job has finished. Video models can take a minute or more to render, so instead of polling the job, you pass a `callback_url` when you submit it and OpenRouter calls that URL once the job reaches a final state: completed, failed, cancelled or expired. OpenRouter is a unified API across many model providers, and these callbacks are its signed webhooks.

This guide covers how video callbacks are configured and signed, the payload you receive, the comma that trips up most verifiers, and the practices that keep an OpenRouter receiver reliable.

## What are OpenRouter webhooks?

When you create a video with `POST /api/v1/videos`, you can include a `callback_url`. When the job ends, OpenRouter sends a JSON `POST` to that URL with the job's final state. You can also set a default callback URL for the workspace. A `callback_url` on the request takes priority over it.

Each callback is signed with a webhook signing secret configured in your OpenRouter workspace settings. The `X-OpenRouter-Signature` header looks like `t=<unix seconds>,v1=<hex>`, and the signature is an HMAC-SHA256 of the `t` value, a comma and the raw body. The separator is a comma, not the full stop that Stripe-style schemes use. A second header, `X-OpenRouter-Idempotency-Key`, identifies the callback so you can deduplicate retries.

OpenRouter also has a Broadcast feature with a webhook destination for traces. That one is a different feature and is not signed. This guide is about video generation callbacks.

## OpenRouter webhook features

| Feature | Details |
| --- | --- |
| Scope | Asynchronous video generation jobs |
| Configuration | `callback_url` on `POST /api/v1/videos`, or a workspace default callback URL |
| Payload | `{ "type", "created_at", "data" }` |
| Events | `video.generation.completed`, `.failed`, `.cancelled`, `.expired` |
| Signature header | `X-OpenRouter-Signature: t=<unix seconds>,v1=<hex>` |
| Idempotency header | `X-OpenRouter-Idempotency-Key` |
| Scheme | HMAC-SHA256, hex digest |
| Signed content | `{t},{raw body}` |
| Secret | A webhook signing secret in the workspace settings, used as a UTF-8 string |
| Replay guidance | OpenRouter's receiver rejects `t` more than 300 seconds from now |
| Expected response | `204` (or another `2xx`) for a valid callback |

OpenRouter doesn't document its retry schedule, response timeout or source IP addresses. It does say callbacks are retried, which is why the idempotency key exists.

## Common events

The `type` field names the final state of the job:

| Event | `data.status` |
| --- | --- |
| `video.generation.completed` | `completed` |
| `video.generation.failed` | `failed` |
| `video.generation.cancelled` | `cancelled` |
| `video.generation.expired` | `expired` |

OpenRouter's docs show the envelope as a TypeScript type. A completed callback looks like this:

```json
{
  "type": "video.generation.completed",
  "created_at": "2026-05-01T12:00:41.000Z",
  "data": {
    "id": "Nxff2D1Z6w4Zk9iNuZam",
    "status": "completed",
    "generation_id": "gen-...",
    "model": "google/veo-3.1-lite",
    "unsigned_urls": ["https://..."],
    "usage": { "cost": 0.4, "is_byok": false }
  }
}

```

The values are illustrative. Depending on the state, `data` includes `id`, `status`, `generation_id`, `model`, `unsigned_urls`, `usage` and `error`, and the optional fields can be missing or `null`. On success, download the video from the first `unsigned_urls` entry or from the content endpoint. If the URL points to the OpenRouter API, send your bearer token with the download request.

## Setting up OpenRouter webhooks

1. In your OpenRouter workspace settings, configure a webhook signing secret. Store it server-side, for example as `OPENROUTER_WEBHOOK_SECRET`.
2. Make sure your receiver is reachable over public HTTPS.
3. Submit a video job with `callback_url` set to your receiver:

```bash
curl https://openrouter.ai/api/v1/videos \
  -H "Authorization: Bearer $OPENROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "google/veo-3.1-lite",
    "prompt": "A matte black desk lamp on a studio table, slow camera slide",
    "duration": 4,
    "resolution": "720p",
    "callback_url": "https://example.com/webhooks/openrouter"
  }'

```

Submitting a job starts a real generation and can spend credits. OpenRouter suggests testing your receiver first with a body you sign yourself: sign `"<t>," + body` with your secret and send it with `X-OpenRouter-Signature: t=<t>,v1=<hex>`.

If you use Hookdeck, create a source with the OpenRouter source type, paste the workspace signing secret into it, and use the Hookdeck source URL as the `callback_url` or workspace default. OpenRouter signs only video-generation callbacks (the `callback_url` of a video request), so use this source for those callbacks. The source accepts `POST` only, which is what OpenRouter sends.

## Securing OpenRouter webhooks

Parse `X-OpenRouter-Signature` into its `t` value and its `v1` values. Build the signed content from `t`, a comma and the raw body. Compute an HMAC-SHA256 with the secret string as the key, encode it as hex, and compare it in constant time with each `v1` value. A match on any of them is enough.

OpenRouter's sample receiver also rejects a `t` more than 300 seconds from the current time. Below, that check is optional and off by default.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyOpenRouterSignature(rawBody, header, secret, toleranceSeconds = 0) {
  if (!header || !secret) return false;

  const parts = header.split(',').map((part) => part.trim());
  const t = parts.find((part) => part.startsWith('t='))?.slice(2);
  const signatures = parts.filter((part) => part.startsWith('v1=')).map((part) => part.slice(3));
  if (!t || signatures.length === 0) return false;

  // Optional replay check, off by default.
  if (toleranceSeconds > 0) {
    if (!/^\d+$/.test(t)) return false;
    if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false;
  }

  // A comma, not a dot, between the timestamp and the body.
  const expected = crypto
    .createHmac('sha256', secret)
    .update(t, 'latin1')
    .update(',')
    .update(rawBody)
    .digest('hex');
  const computed = Buffer.from(expected, 'utf8');

  return signatures.some((signature) => {
    if (!/^[0-9a-fA-F]{64}$/.test(signature)) return false;
    const received = Buffer.from(signature.toLowerCase(), 'utf8');
    return received.length === computed.length && crypto.timingSafeEqual(received, computed);
  });
}

const app = express();

app.post('/webhooks/openrouter', express.raw({ type: '*/*' }), (req, res) => {
  const ok = verifyOpenRouterSignature(
    req.body,
    req.get('x-openrouter-signature'),
    process.env.OPENROUTER_WEBHOOK_SECRET
  );
  if (!ok) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on the X-OpenRouter-Idempotency-Key header, then store the job state.
  res.sendStatus(204);
});

```

The same check in Python:

```python
import hashlib
import hmac
import re
import time

HEX_64 = re.compile(r"[0-9a-fA-F]{64}")

def verify_openrouter_signature(raw_body: bytes, header, secret: str,
                                tolerance_seconds: int = 0) -> bool:
    if not header or not secret:
        return False
    parts = [part.strip() for part in header.split(",")]
    t = next((part[2:] for part in parts if part.startswith("t=")), None)
    signatures = [part[3:] for part in parts if part.startswith("v1=")]
    if not t or not signatures:
        return False
    # Optional replay check, off by default.
    if tolerance_seconds > 0:
        if not t.isdigit() or abs(time.time() - int(t)) > tolerance_seconds:
            return False

    signed = t.encode("latin-1") + b"," + raw_body  # comma, not dot
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest().encode("utf-8")
    return any(
        HEX_64.fullmatch(sig) and hmac.compare_digest(sig.lower().encode("utf-8"), expected)
        for sig in signatures
    )

# In FastAPI: raw_body = await request.body()
# verify_openrouter_signature(raw_body, request.headers.get("x-openrouter-signature"),
#                             os.environ["OPENROUTER_WEBHOOK_SECRET"])

```

Hookdeck's OpenRouter source type reads `t` and `v1` from the header, verifies HMAC-SHA256 over `{t},{body}` in hex, and applies no timestamp tolerance. A request that fails the check is marked as failed verification and is not delivered to your destinations.

> **Make OpenRouter webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-OpenRouter-Signature` header on every video callback, stores each one, and delivers it to your service with retries you control.

## OpenRouter webhook limitations and pain points

### A comma where you expect a dot

**The Problem:** Code adapted from a Stripe-style verifier fails on every callback.

**Why It Happens:** The header looks like Stripe's `t=...,v1=...`, so it is natural to sign `t.body`. OpenRouter signs `t,body`. The digest is still well-formed hex, so the error is easy to miss.

**Workarounds:**

* Build the signed content as `t`, `,`, raw body.
* Test with a body you sign yourself before you submit a real job.

**How Hookdeck Can Help:** Hookdeck's OpenRouter source uses the comma separator, so your service doesn't have to get it right.

### Only video jobs send signed webhooks

**The Problem:** You expect signed webhooks for other OpenRouter activity and find none, or you point the Broadcast trace destination at a verifier and every request fails.

**Why It Happens:** OpenRouter signs callbacks for video generation jobs. The Broadcast webhook destination sends traces without a signature.

**Workarounds:**

* Use callbacks only for video jobs, and use a separate, unverified endpoint for Broadcast traces.
* Authenticate Broadcast traffic with the custom headers that feature supports.

**How Hookdeck Can Help:** Use the OpenRouter source type for video callbacks only, and a separate source for anything else, so verification failures mean what they say.

### The 300-second window and redeliveries

**The Problem:** A callback that is retried or replayed later fails your own check.

**Why It Happens:** OpenRouter's sample rejects a `t` older than 300 seconds. Hookdeck retries and replays resend the original header, so a later redelivery carries the original `t`.

**Workarounds:**

* Leave the window off and deduplicate on `X-OpenRouter-Idempotency-Key`.
* If you keep a window, make it longer than any retry or replay delay you allow.

**How Hookdeck Can Help:** Hookdeck applies no timestamp tolerance, so its retries and replays still verify at the source.

### A thin delivery contract

**The Problem:** You don't know how long OpenRouter retries, or how long it waits for your response.

**Why It Happens:** OpenRouter's documentation mentions retries but no schedule, timeout or source IPs.

**Workarounds:**

* Answer quickly with a `2xx` and do the download afterwards.
* Keep the job's `polling_url` and poll any job with no callback after a reasonable time.

**How Hookdeck Can Help:** Hookdeck accepts each callback as soon as it arrives and delivers it to your service with [retries](/docs/retries) you configure.

## Best practices

### Verify against the raw body

OpenRouter says verification "must use the exact bytes OpenRouter sent, not a re-serialized payload". Use `express.raw({ type: '*/*' })` in Express, `Buffer.from(await request.arrayBuffer())` in Next.js and `await request.body()` in FastAPI.

### Deduplicate on `X-OpenRouter-Idempotency-Key`

OpenRouter tells you to deduplicate retries with this header. Store each key once processed and acknowledge repeats without redoing work. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Treat the callback as a final state

Each callback is the end of a job. Store `data.status` against the job ID, then download or report the failure. Don't expect progress events.

### Download promptly and asynchronously

Return `204` once the event is stored, then download the video from `unsigned_urls` or the content endpoint in the background. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Use per-request URLs for preview environments

Because `callback_url` overrides the workspace default, you can send preview or tenant-specific jobs to their own receivers without changing the default.

## Conclusion

OpenRouter signs video generation callbacks with `t=<seconds>,v1=<hex>`, where the hex is an HMAC-SHA256 of `t`, a comma and the raw body. Use the comma, verify against the raw bytes, deduplicate on `X-OpenRouter-Idempotency-Key`, and treat each callback as the final state of a job.

[Hookdeck Event Gateway](https://hookdeck.com) verifies every OpenRouter callback and holds it until your service has processed it, so a finished video isn't lost to an outage on your side.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle OpenRouter webhooks reliably in minutes.