webhooks
Videos, async images, music, async speech, voice clones and avatars are async tasks: the submit answers with a task id and the result comes later. Instead of polling the task's GET, you can pass a callback_url when you submit, and the platform POSTs the result to it once the task has finished.
1. Create a signing secret#
On the console's API keys page, create a webhook signing secret (whsec_…) under Webhook signing secret. It only proves that a callback really comes from us; keep it as safe as an API key.
- Without a secret, a submit's
callback_urlis ignored and the answer carriesX-Webhook: ignored_no_secret. - After a rotation the previous secret keeps signing for 24 hours: every callback in that window carries two signatures and verifies with either secret, so you can switch without missing one.
2. Pass callback_url when you submit#
curl https://api.dflop.top/v1/videos/generations \
-H "Authorization: Bearer $XIAOZHANAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "doubao-seedance-2.0",
"prompt": "A sunset over the sea, slow push-in",
"callback_url": "https://your.app/hooks/xiaozhanai"
}'
| Task | Submit | Condition |
|---|---|---|
| Video | POST /v1/videos/generations | — |
| Image | POST /v1/images/generations, POST /v1/images/edits | "async": true |
| Music | POST /v1/music/generations | — |
| Speech | POST /v1/audio/speech | "async": true |
| Voice clone | POST /v1/audio/voices | "async": true |
| Avatar | POST /v1/videos/avatars | — |
The submit's X-Webhook response header says whether the address was registered:
| Value | Meaning |
|---|---|
registered | Registered; you get a callback when the task finishes |
ignored_no_secret | The account has no signing secret yet |
ignored_sync | A blocking request (no "async": true): the result is in the answer, no callback |
ignored_invalid_url | Not usable: not http or https, a port other than 80 or 443, an internal or reserved address, or longer than 2048 characters |
ignored_unavailable | The signing secret could not be checked just now; no callback for this task, poll as usual |
Registered or not, callback_url never reaches the model provider.
3. The request you receive#
When the task has finished, the platform POSTs to your callback_url:
POST /hooks/xiaozhanai HTTP/1.1
content-type: application/json
user-agent: xiaozhanai-webhooks/1
webhook-id: evt_6f1c…
webhook-timestamp: 1731600000
webhook-signature: v1,K7f3…=
{"id":"evt_6f1c…","object":"event","type":"video.completed","created_at":1731600000,"data":{…}}
typeis<task>.completed(success:succeeded, orreadyfor voices and avatars) or<task>.failed(failed,expired,cancelled);data.statusis the exact final state. Tasks:video,image,music,speech,voice,avatar.datais what the task'sGETanswered at the first delivery attempt (for a video,GET /v1/videos/generations/{id}).- A finished image task's
GETreplays the generation result as is, without a task id, so an image event'sdatais{id, status, model, created_at, http_status, result}, whereresultis that replay. - When the result carries inline base64 images, or
datawould exceed 256 KB,datais only{id, status, truncated: true}; read the rest withGET. - A finished video is reported once its file has been copied to our storage (at most 15 minutes after it finished), so the event's
video_urlis the long-lived one. - An avatar that is
readycan later be invalidated by the upstream and becomefailed. That change sends no second event;GETis authoritative.
4. Verify the signature#
Signatures follow Standard Webhooks: HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{raw body}, keyed with the base64-decoded part of the secret after whsec_, base64-encoded and written as v1,<signature>. webhook-signature may hold several signatures separated by spaces; any match is enough. Also reject timestamps more than 5 minutes from now, and always verify the raw request body, not a re-serialized one.
The official OpenAI SDKs' webhooks.unwrap() checks exactly this format:
import os
from openai import OpenAI
client = OpenAI(api_key="unused", webhook_secret=os.environ["XIAOZHANAI_WEBHOOK_SECRET"])
def handle(raw_body: bytes, headers: dict):
event = client.webhooks.unwrap(raw_body, headers) # raises InvalidWebhookSignatureError on a bad signature
print(event.type, event.data.id)
import OpenAI from "openai";
const client = new OpenAI({ apiKey: "unused", webhookSecret: process.env.XIAOZHANAI_WEBHOOK_SECRET });
export async function handle(rawBody, headers) {
const event = await client.webhooks.unwrap(rawBody, headers); // throws on a bad signature
console.log(event.type, event.data.id);
}
Without an SDK it is a few lines:
import base64, hashlib, hmac, time
def verify(secret: str, headers: dict, raw_body: bytes) -> bool:
msg_id, ts = headers["webhook-id"], headers["webhook-timestamp"]
if abs(time.time() - int(ts)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
expected = base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + raw_body, hashlib.sha256).digest()).decode()
return any(hmac.compare_digest(expected, s.removeprefix("v1,")) for s in headers["webhook-signature"].split())
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, headers, rawBody /* Buffer */) {
const id = headers["webhook-id"];
const ts = headers["webhook-timestamp"];
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = Buffer.from(createHmac("sha256", key).update(`${id}.${ts}.`).update(rawBody).digest("base64"));
return headers["webhook-signature"].split(" ").some((s) => {
const sig = Buffer.from(s.replace(/^v1,/, ""));
return sig.length === expected.length && timingSafeEqual(sig, expected);
});
}
5. Answering and retries#
- A 2xx answer within 10 seconds counts as delivered. Anything else (another status, a timeout, no connection) is retried after 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours and 24 hours, 7 attempts in all, then given up.
- Redirects are not followed; only the first 4 KB of your answer is kept, for troubleshooting.
- Delivery is at least once: the same event can arrive more than once, so deduplicate on
webhook-id. A retry sends exactly the same body. - Events are not ordered.
- A task that has not finished 48 hours after it was submitted gets no callback.
6. Delivery status#
The task's GET gains a webhook field:
"webhook": {"state": "delivered", "attempts": 1, "last_status": 200, "delivered_at": "2026-11-14T16:20:00.123+00:00"}
state is pending (waiting for the task or for a retry), delivered, failed (retries used up) or abandoned (the task is gone, it did not finish within 48 hours, or the account was deleted). A finished image task's GET replays its result as is, so its delivery status is in the X-Webhook-State and X-Webhook-Attempts response headers instead.
7. Not supported yet#
- Smart subtitle tasks (
/v1/videos/clip-subtitles) have no callback yet; poll them. - The callback address has to be a public http or https address on port 80 or 443.