Agent skill
OpenRouter Webhooks Skill
Receive and verify OpenRouter video-generation webhooks (the callback_url of a video job). Use when setting up OpenRouter webhook handlers, debugging X-OpenRouter-Signature verification, or handling video.generation.completed, video.generation.failed, video.generation.cancelled and video.generation.expired events.
Install this skill
npx skills add hookdeck/webhook-skills --skill openrouter-webhooks
When to Use This Skill
- How do I receive OpenRouter webhooks for a video job's
callback_url? - How do I verify the OpenRouter
X-OpenRouter-Signatureheader? - How do I handle
video.generation.completedand download the finished video? - How do I handle
video.generation.failed,cancelledorexpiredjobs? - Why is my OpenRouter webhook signature verification failing?
OpenRouter signs only video-generation callbacks (the callback_url of a video request). Use this source for those callbacks.
OpenRouter's Broadcast "Webhook" destination (OTLP traces) is unsigned and is not covered by this skill.
Verification (core)
Every callback carries X-OpenRouter-Signature: t=<unix seconds>,v1=<hex>. The v1 value is lowercase hex HMAC-SHA256, keyed with the workspace webhook signing secret (its UTF-8 bytes, used as-is), over the bytes "<t>," (a literal comma, not a dot) followed by the raw request body.
const crypto = require('crypto');
function verifyOpenRouterSignature(rawBody, header, secret) {
if (!header || !secret) return false;
let t = null;
const sigs = [];
for (const part of header.split(',')) {
const p = part.trim();
if (p.startsWith('t=') && t === null) t = p.slice(2);
else if (p.startsWith('v1=')) sigs.push(p.slice(3));
}
if (!t || sigs.length === 0) return false;
const signed = Buffer.concat([Buffer.from(`${t},`, 'latin1'), rawBody]); // bytes, never a re-encoded string
const expected = crypto.createHmac('sha256', secret).update(signed).digest();
return sigs.some((s) => /^[0-9a-fA-F]{64}$/.test(s) &&
crypto.timingSafeEqual(expected, Buffer.from(s, 'hex')));
}
Respond 204 for a valid delivery and 401 for a missing, malformed or mismatched signature. Read the raw body first (express.raw({ type: '*/*' }), await request.arrayBuffer(), await request.body()) and parse JSON only after the check.
Timestamp window is off by default. OpenRouter's own receiver example rejects t older than 300 s, but Hookdeck retries and replays resend the original headers and timestamp (and Hookdeck applies no tolerance itself), so a fixed window would reject legitimate retries. The examples offer an opt-in: set OPENROUTER_ to enforce one (unset or 0 = off). Use idempotency instead (see below).
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
Common Event Types
Dispatch on the body's type field; the job is in data (id, status, generation_id, model, unsigned_urls, usage, error).
| Event | Triggered When |
|---|---|
video.generation.completed | The job finished; download the video from data.unsigned_urls[0] |
video.generation.failed | The job failed; see data.error |
video.generation.cancelled | The job was cancelled |
video.generation.expired | The job expired before completing |
Each delivery also has an X-OpenRouter-Idempotency-Key header (e.g. job_test-completed); use it to deduplicate retries. The retry schedule is not documented.
Environment Variables
# Webhook signing secret from your OpenRouter workspace settings
OPENROUTER_WEBHOOK_SECRET=your_openrouter_webhook_signing_secret
# Optional: reject signatures older than N seconds (unset or 0 = off)
OPENROUTER_WEBHOOK_TOLERANCE_SECONDS=0
Local Development
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 openrouter --path /webhooks/openrouter
Use the printed public HTTPS URL as the callback_url of a POST /api/v1/videos request (or as the workspace default callback URL).
Reference Materials
- references/overview.md - How video callbacks work, events, payload shape
- references/setup.md - Signing secret and
callback_urlconfiguration - references/verification.md - Signature verification details and gotchas