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_ |
| 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:
{
"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
- In your OpenRouter workspace settings, configure a webhook signing secret. Store it server-side, for example as
OPENROUTER_.WEBHOOK_ SECRET - Make sure your receiver is reachable over public HTTPS.
- Submit a video job with
callback_urlset to your receiver:
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.
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:
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 verifies the
X-OpenRouter-Signatureheader 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
2xxand do the download afterwards. - Keep the job's
polling_urland 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 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.
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.
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 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 for free and handle OpenRouter webhooks reliably in minutes.