Platform

Callbacks (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.

EndpointWhat happens
POST /v1/videosStarts the video as usual. When it ends, the callback brings the finished video (or why it failed). No polling needed.
POST /v1/images/generationsAnswers 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/editsThe same, for edits: a JSON field, or a form field in a multipart request.
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"
}'

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, or the image request above), with download links that work for 24 hours.

EventWhen
video.completedThe video is ready: data.url downloads it.
video.failedIt couldn’t be made: data.error says why. You weren’t charged.
image.completedThe images are ready: data.data holds a link to each.
image.failedNo 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)
{
"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"
}
}
HeaderValue
webhook-idmsg_…: the same on every retry of this callback. Use it to skip one you’ve already handled.
webhook-timestampWhen this attempt was sent, in Unix seconds.
webhook-signaturev1, then the signature (see below). Two of them, space-separated, for a day after you rotate your secret.
user-agentZurelay-Webhooks/1.0

Verify the signature

Callbacks follow the Standard Webhooks format, the same one OpenAI’s webhooks use. The signature is an HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<body>, keyed with your workspace’s signing secret, in base64. Find the secret (it starts with whsec_) on the dashboard’s API keys page, and keep it on your server only.

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}
  • 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:

AttemptSent
1As soon as the job ends
21 minute later
35 minutes after that
430 minutes after that
52 hours after that
68 hours after that, then it’s marked failed

Every callback shows with its request in the dashboard’s 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.

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.

Questions, or something missing? Ask our support team and we’ll answer by email, usually within a minute.