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_url is ignored and the answer carries X-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"
  }'
TaskSubmitCondition
VideoPOST /v1/videos/generations—
ImagePOST /v1/images/generations, POST /v1/images/edits"async": true
MusicPOST /v1/music/generations—
SpeechPOST /v1/audio/speech"async": true
Voice clonePOST /v1/audio/voices"async": true
AvatarPOST /v1/videos/avatars—

The submit's X-Webhook response header says whether the address was registered:

ValueMeaning
registeredRegistered; you get a callback when the task finishes
ignored_no_secretThe account has no signing secret yet
ignored_syncA blocking request (no "async": true): the result is in the answer, no callback
ignored_invalid_urlNot usable: not http or https, a port other than 80 or 443, an internal or reserved address, or longer than 2048 characters
ignored_unavailableThe 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":{…}}
  • type is <task>.completed (success: succeeded, or ready for voices and avatars) or <task>.failed (failed, expired, cancelled); data.status is the exact final state. Tasks: video, image, music, speech, voice, avatar.
  • data is what the task's GET answered at the first delivery attempt (for a video, GET /v1/videos/generations/{id}).
  • A finished image task's GET replays the generation result as is, without a task id, so an image event's data is {id, status, model, created_at, http_status, result}, where result is that replay.
  • When the result carries inline base64 images, or data would exceed 256 KB, data is only {id, status, truncated: true}; read the rest with GET.
  • 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_url is the long-lived one.
  • An avatar that is ready can later be invalidated by the upstream and become failed. That change sends no second event; GET is 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.