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-Signature header?
  • How do I handle video.generation.completed and download the finished video?
  • How do I handle video.generation.failed, cancelled or expired jobs?
  • 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_WEBHOOK_TOLERANCE_SECONDS=300 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).

EventTriggered When
video.generation.completedThe job finished; download the video from data.unsigned_urls[0]
video.generation.failedThe job failed; see data.error
video.generation.cancelledThe job was cancelled
video.generation.expiredThe 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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 11, 2026

View on GitHub →