# Callbacks (webhooks) > Get images and videos pushed to your server when they're ready: callback_url, signed Standard Webhooks deliveries, retries, and verifying the signature. Source: https://zurelay.com/docs/webhooks Instead of polling, give an image or video request a `callback_url`. When it’s ready, or if it fails, Zurelay POSTs the result to that URL, signed so you can tell it came from us. ## Ask for a callback Add `callback_url` to any of these requests. It must be an https URL on the public internet. | Endpoint | What happens | | --- | --- | | `POST /v1/videos` | Starts the video as usual. When it ends, the callback brings the finished video (or why it failed). No polling needed. | | `POST /v1/images/generations` | Answers at once with 202 and the request’s id instead of waiting for the images, makes them in the background, then calls back. | | `POST /v1/images/edits` | The same, for edits: a JSON field, or a form field in a multipart request. | **cURL (video)**: ``` curl https://api.zurelay.com/v1/videos \ -H "Authorization: Bearer $ZURELAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "seedance-2.0", "prompt": "A fox running through fresh snow at dawn", "seconds": 5, "callback_url": "https://example.com/hooks/zurelay" }' ``` **Python (image)** (`image.py`): ```python import os, requests response = requests.post( "https://api.zurelay.com/v1/images/generations", headers={"Authorization": f"Bearer {os.environ['ZURELAY_API_KEY']}"}, json={ "model": "nano-banana-pro", "prompt": "A lighthouse on a cliff at golden hour", "callback_url": "https://example.com/hooks/zurelay", }, ) job = response.json() # 202: {"id": "req_…", "status": "in_progress", …} print(job["id"]) ``` **Node.js (image)** (`image.mjs`): ```javascript const response = await fetch("https://api.zurelay.com/v1/images/generations", { method: "POST", headers: { authorization: "Bearer " + process.env.ZURELAY_API_KEY, "content-type": "application/json", }, body: JSON.stringify({ model: "nano-banana-pro", prompt: "A lighthouse on a cliff at golden hour", callback_url: "https://example.com/hooks/zurelay", }), }); const job = await response.json(); // 202: { id: "req_…", status: "in_progress", … } console.log(job.id); ``` ### Image requests with a callback Without `callback_url`, an image request waits and answers with the images, as OpenAI’s API does. With it, the request is accepted straight away and answers with the job. It’s charged only if images are made: Response: ``` HTTP/1.1 202 Accepted x-request-id: req_6fJd0kQ3pW9xT2mB7cZ1hR4u { "id": "req_6fJd0kQ3pW9xT2mB7cZ1hR4u", "object": "image.generation", "model": "nano-banana-pro", "status": "in_progress", "created_at": 1791000000, "completed_at": null, "data": [], "url_expires_at": null, "cost": null, "error": null, "callback_url": "https://example.com/hooks/zurelay" } ``` `GET /v1/images/{id}` You can also poll it: `GET /v1/images/{id}` returns the same object, with fresh links in `data` once its `status` is `completed`. Callbacks always carry links, never base64, whatever `response_format` says. ## What we send One POST per event, with a JSON body: `type`, `timestamp` and `data`, the job exactly as the API returns it ([the video object](https://zurelay.com/docs/video), or the image request above), with download links that work for 24 hours. | Event | When | | --- | --- | | `video.completed` | The video is ready: data.url downloads it. | | `video.failed` | It couldn’t be made: data.error says why. You weren’t charged. | | `image.completed` | The images are ready: data.data holds a link to each. | | `image.failed` | No image was made: data.error says why. You weren’t charged. | A video callback: ``` POST /hooks/zurelay HTTP/1.1 content-type: application/json user-agent: Zurelay-Webhooks/1.0 (+https://zurelay.com/docs/webhooks) webhook-id: msg_2c1f9e0b7a4d4f6e8b3a91c05d7e6f42 webhook-timestamp: 1791000094 webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4= { "type": "video.completed", "timestamp": "2026-10-03T03:21:34.000Z", "data": { "id": "video_Qm3v8Rk2Lx7Pa1Zt9Ws4Yn6B", "object": "video", "model": "seedance-2.0", "status": "completed", "progress": 100, "created_at": 1791000000, "completed_at": 1791000093, "expires_at": 1793592000, "seconds": 5, "resolution": "720p", "aspect_ratio": "16:9", "size": "1280x720", "generate_audio": true, "url": "https://…/video_Qm3v8Rk2Lx7Pa1Zt9Ws4Yn6B.mp4?token=…", "url_expires_at": 1791086494, "cost": 0.45, "error": null, "callback_url": "https://example.com/hooks/zurelay" } } ``` An image callback (body): ```json { "type": "image.completed", "timestamp": "2026-10-03T03:20:41.000Z", "data": { "id": "req_6fJd0kQ3pW9xT2mB7cZ1hR4u", "object": "image.generation", "model": "nano-banana-pro", "status": "completed", "created_at": 1791000000, "completed_at": 1791000041, "data": [{ "url": "https://…/req_6fJd0kQ3pW9xT2mB7cZ1hR4u-1.png?token=…" }], "url_expires_at": 1791086441, "cost": 0.07, "error": null, "callback_url": "https://example.com/hooks/zurelay" } } ``` | Header | Value | | --- | --- | | `webhook-id` | msg_…: the same on every retry of this callback. Use it to skip one you’ve already handled. | | `webhook-timestamp` | When this attempt was sent, in Unix seconds. | | `webhook-signature` | v1, then the signature (see below). Two of them, space-separated, for a day after you rotate your secret. | | `user-agent` | Zurelay-Webhooks/1.0 | ## Verify the signature Callbacks follow the [Standard Webhooks](https://www.standardwebhooks.com) format, the same one OpenAI’s webhooks use. The signature is an HMAC-SHA256 of `..`, keyed with your workspace’s signing secret, in base64. Find the secret (it starts with `whsec_`) on the dashboard’s [API keys](https://zurelay.com/app/keys) page, and keep it on your server only. **Node.js** (`server.mjs`): ```javascript import express from "express"; import { Webhook } from "standardwebhooks"; // npm install standardwebhooks const webhook = new Webhook(process.env.ZURELAY_WEBHOOK_SECRET); // whsec_… const app = express(); // The raw body: the signature covers the exact bytes we sent. app.post("/hooks/zurelay", express.raw({ type: "application/json" }), (req, res) => { let event; try { event = webhook.verify(req.body, req.headers); // throws on a bad signature or old timestamp } catch { return res.status(400).end(); } res.status(200).end(); // answer first, then do the work handle(event); // event.type, event.data }); ``` **Python** (`main.py`): ```python import os from fastapi import FastAPI, HTTPException, Request from standardwebhooks.webhooks import Webhook # pip install standardwebhooks webhook = Webhook(os.environ["ZURELAY_WEBHOOK_SECRET"]) # whsec_… app = FastAPI() @app.post("/hooks/zurelay") async def zurelay_hook(request: Request): body = await request.body() # the raw bytes: the signature covers exactly these try: event = webhook.verify(body, dict(request.headers)) except Exception: raise HTTPException(status_code=400) handle(event) # event["type"], event["data"]; keep it quick, or queue it return {"ok": True} ``` **By hand** (`verify.mjs`): ```javascript import crypto from "node:crypto"; /** True if a callback is ours: signed with your secret, in the last 5 minutes. */ function isFromZurelay(rawBody, headers, secret) { const id = headers["webhook-id"]; const timestamp = headers["webhook-timestamp"]; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64"); const expected = crypto .createHmac("sha256", key) .update(`${id}.${timestamp}.${rawBody}`) .digest("base64"); // One "v1," per secret signing now, separated by spaces. return headers["webhook-signature"].split(" ").some((entry) => { const [version, signature] = entry.split(","); return ( version === "v1" && signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)) ); }); } ``` - Check the raw body: parsing and re-serializing the JSON changes the bytes, and the signature with them. - Refuse a `webhook-timestamp` more than five minutes old: it stops anyone replaying a callback they caught. The libraries do this for you. - Rotating the secret (owners and admins, on the API keys page) gives you a new one at once. The old one keeps signing alongside it for 24 hours, so switch your server over within the day. ## Answer, then work Reply with any `2xx` within 15 seconds and the callback counts as delivered. Save the event and answer first; download the video or images afterwards. A callback can arrive more than once (when your answer was lost on the way, say), so use `webhook-id` to do each one only once. ## Retries Anything else (an error status, a redirect, no answer within 15 seconds, or a server we can’t reach) is tried again, six attempts in all: | Attempt | Sent | | --- | --- | | 1 | As soon as the job ends | | 2 | 1 minute later | | 3 | 5 minutes after that | | 4 | 30 minutes after that | | 5 | 2 hours after that | | 6 | 8 hours after that, then it’s marked failed | Every callback shows with its request in the dashboard’s [Requests](https://zurelay.com/app/requests): where it went, each attempt’s answer, and when the next try is. **Retry callback** there sends it again at once, delivered or not. The links inside are fresh on every attempt. > **Tip:** Callbacks go only to https URLs on the public internet: not localhost, private addresses or anything that resolves to one. They don’t follow redirects, so use the final URL. To try them while you build, use a tunnel (ngrok, Cloudflare Tunnel) or a request inspector.