> ## Documentation Index > Fetch the complete documentation index at: https://openrouter.ai/docs/llms.txt > Use this file to discover all available pages before exploring further. # Get Video Results with Webhooks > Submit a video job with a callback URL and verify OpenRouter webhook signatures Use this guide when you need to add webhook-based video completion handling instead of polling from a client or worker. By the end, your implementation should submit a video job with `callback_url` and verify the webhook signature. For reusable agent knowledge across projects, install the [openrouter-video skill](https://github.com/OpenRouterTeam/skills/tree/main/skills/openrouter-video). ## Before you start You need: * An OpenRouter API key available as `OPENROUTER_API_KEY` * Node.js 20 or newer * A public HTTPS endpoint for your webhook receiver * A webhook signing secret configured in your OpenRouter workspace settings * A video model slug for the job you submit with `callback_url` If you haven't chosen a model yet, read [Choose a Video Generation Model](/docs/cookbook/video-generation/choose-video-model) first. It helps you pick one based on clip duration, output shape, input type, audio, provider controls, and cost. Use the API reference pages as the source of truth for exact fields: * [Create video generation request](/docs/api/api-reference/video-generation/submit-a-video-generation-request) * [List video generation models](/docs/api/api-reference/video-generation/list-all-video-generation-models) * [TypeScript SDK video generation reference](/docs/client-sdks/typescript/sdks/videogeneration/README) If you adapt the Express examples below in a local test project, use these dependencies: ```bash lines theme={null} npm install express npm install --save-dev @types/express tsx ``` Submitting `POST /api/v1/videos` starts a real video generation job and may spend OpenRouter credits. ## Step 1: Implement a webhook receiver Add a webhook receiver that preserves the raw request body before parsing JSON. Signature verification must use the exact bytes OpenRouter sent, not a re-serialized payload. Example Express receiver: ```ts expandable lines theme={null} import crypto from "node:crypto"; import express from "express"; const app = express(); const signingSecret = process.env.OPENROUTER_WEBHOOK_SECRET; type VideoWebhookEvent = { type: | "video.generation.completed" | "video.generation.failed" | "video.generation.cancelled" | "video.generation.expired"; created_at: string; data: { id: string; status: "completed" | "failed" | "cancelled" | "expired"; generation_id?: string | null; model?: string | null; unsigned_urls?: string[]; usage?: { cost?: number; is_byok?: boolean; }; error?: string; }; }; function verifyOpenRouterSignature(rawBody: Buffer, header: string): boolean { if (!signingSecret) return false; const parts = header.split(",").map((part) => part.trim()); const timestamp = parts.find((part) => part.startsWith("t="))?.slice(2); const signature = parts.find((part) => part.startsWith("v1="))?.slice(3); if (!timestamp || !signature) return false; const age = Math.floor(Date.now() / 1000) - Number(timestamp); if (Number.isNaN(age) || Math.abs(age) > 300) return false; const signedPayload = Buffer.concat([ Buffer.from(`${timestamp},`, "utf8"), rawBody, ]); const expected = crypto .createHmac("sha256", signingSecret) .update(signedPayload) .digest("hex"); if (expected.length !== signature.length) return false; return crypto.timingSafeEqual( Buffer.from(expected), Buffer.from(signature), ); } app.post( "/openrouter/video-webhook", express.raw({ type: "application/json" }), (req, res) => { const signature = req.header("X-OpenRouter-Signature"); if (!signature || !verifyOpenRouterSignature(req.body, signature)) { return res.sendStatus(401); } const idempotencyKey = req.header("X-OpenRouter-Idempotency-Key"); const event = JSON.parse(req.body.toString("utf8")) as VideoWebhookEvent; const job = event.data; if (job.status === "completed") { console.log("Video ready:", { id: job.id, idempotencyKey, url: job.unsigned_urls?.[0], }); } if (["failed", "cancelled", "expired"].includes(job.status)) { console.error("Video did not complete:", { id: job.id, status: job.status, error: job.error, idempotencyKey, }); } res.sendStatus(204); }, ); app.listen(3000, () => { console.log("Listening on http://localhost:3000"); }); ``` ## Step 2: Validate signature handling before using real jobs Before connecting a real `callback_url`, exercise the receiver with the same signing secret your test sender uses: ```bash lines theme={null} OPENROUTER_WEBHOOK_SECRET=dev_secret npx tsx server.ts ``` Actual local receiver startup output: ```text lines theme={null} Listening on http://localhost:3000 ``` Expose the receiver with a public HTTPS URL before using it as a real `callback_url`. A local tunnel or deployed preview URL works as long as OpenRouter can reach it over HTTPS. ## Step 3: Send a signed test event Before spending credits on a real video job, test the receiver with a locally signed event. This verifies that raw-body handling, timestamp parsing, HMAC comparison, and idempotency headers are wired correctly. Example local sender: ```js expandable lines theme={null} import crypto from "node:crypto"; const secret = process.env.OPENROUTER_WEBHOOK_SECRET; if (!secret) { throw new Error("Set OPENROUTER_WEBHOOK_SECRET first."); } const body = JSON.stringify({ type: "video.generation.completed", created_at: new Date().toISOString(), data: { id: "job_test", status: "completed", unsigned_urls: ["https://example.com/video.mp4"], }, }); const timestamp = Math.floor(Date.now() / 1000).toString(); const signature = crypto .createHmac("sha256", secret) .update(`${timestamp},${body}`) .digest("hex"); const response = await fetch("http://localhost:3000/openrouter/video-webhook", { method: "POST", headers: { "Content-Type": "application/json", "X-OpenRouter-Signature": `t=${timestamp},v1=${signature}`, "X-OpenRouter-Idempotency-Key": "job_test-completed", }, body, }); console.log(response.status); ``` Exercise the local sender while the receiver is listening: ```bash lines theme={null} OPENROUTER_WEBHOOK_SECRET=dev_secret node send-test-webhook.mjs ``` A valid signed event should return `204`. Change the secret or signature to confirm the receiver returns `401` for invalid requests. Actual local signature-test output: ```text lines theme={null} 204 ``` You can also use a temporary Webhook.site URL as `CALLBACK_URL` to confirm OpenRouter delivers the webhook and includes the expected headers and envelope. Webhook.site doesn't run your signature verifier; use your own public receiver with the workspace signing secret for end-to-end signature verification. Example Webhook.site delivery: ```json expandable lines theme={null} { "request": { "method": "POST", "content_type": "application/json", "has_signature_header": true, "signature": { "has_timestamp": true, "has_v1": true, "redacted_format": "t=,v1=" }, "has_idempotency_key_header": true, "idempotency_key_shape": { "includes_job_id": true, "length": 30 }, "body_shape": { "top_level_keys": ["created_at", "data", "type"], "type": "video.generation.completed", "data_keys": [ "generation_id", "id", "model", "status", "unsigned_urls", "usage" ], "data_id_matches_job": true, "data_status": "completed", "unsigned_url_count": 1, "usage_keys": ["cost", "is_byok"] } }, "job": { "id": "Nxff2D1Z6w4Zk9iNuZam", "poll_statuses": [ { "status": "pending", "elapsed_seconds": 1 }, { "status": "pending", "elapsed_seconds": 11 }, { "status": "pending", "elapsed_seconds": 21 }, { "status": "pending", "elapsed_seconds": 31 }, { "status": "completed", "elapsed_seconds": 41 } ], "downloaded_bytes": 442723 } } ``` ## Step 4: Submit a video job with `callback_url` Once the receiver is reachable over public HTTPS, submit the video job with `callback_url`. You can set the callback URL per request, which is useful for preview environments or tenant-specific receivers. Example submit logic: ```ts expandable lines theme={null} const apiKey = process.env.OPENROUTER_API_KEY; const callbackUrl = process.env.CALLBACK_URL; if (!apiKey) { throw new Error("Set OPENROUTER_API_KEY first."); } if (!callbackUrl) { throw new Error("Set CALLBACK_URL to your public HTTPS receiver URL."); } const response = await fetch("https://openrouter.ai/api/v1/videos", { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", }, body: JSON.stringify({ model: "google/veo-3.1-lite", prompt: "A clean product reveal of a matte black desk lamp, slow camera slide, studio lighting", duration: 4, resolution: "720p", aspect_ratio: "16:9", generate_audio: false, callback_url: callbackUrl, }), }); if (!response.ok) { throw new Error(await response.text()); } console.log(await response.json()); ``` The submit call returns the initial job fields. In a completed run, that job later completed and delivered a webhook with this final summary: ```json lines theme={null} { "id": "Nxff2D1Z6w4Zk9iNuZam", "initial_status": "pending", "terminal_status": "completed", "response_keys": ["id", "polling_url", "status"] } ``` After the receiver is deployed or exposed through a tunnel, run the submit logic with `CALLBACK_URL` set to that public endpoint: ```bash lines theme={null} CALLBACK_URL=https://your-app.example.com/openrouter/video-webhook npx tsx submit-video-job.mts ``` The per-request `callback_url` takes priority over a workspace-level default callback URL. ## Step 5: Handle the completed job Handle webhook delivery as a terminal job update. The payload is an event envelope with the job fields inside `data`. Depending on the terminal state, `data` includes fields such as `id`, `status`, `generation_id`, `model`, `unsigned_urls`, `usage`, and `error`. Store the job state in your database, deduplicate retries with `X-OpenRouter-Idempotency-Key`, then download the video from the first `unsigned_urls` entry or from the content endpoint. If the URL points to the OpenRouter API, include the bearer token when downloading it. For a complete polling and download helper, see [Generate and Download a Video from Text](/docs/cookbook/video-generation/text-to-video). Actual local receiver log shape from the signature test: ```text lines theme={null} Video ready: { id: "job_test", idempotencyKey: "job_test-completed", url: "https://example.com/video.mp4" } ``` ## Check your work Your receiver should return `204` for a valid OpenRouter webhook and `401` for a request with a missing or invalid signature. A real callback delivery should produce a terminal job update that your app can store and use to download the generated video.