# webhooks

来源：https://model.dflop.top/en/docs/guides/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

```bash
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`:

```http
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](https://www.standardwebhooks.com): 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:

```python
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)
```

```javascript
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:

```python
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())
```

```javascript
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:

```json
"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.
