Build it into your app
Result callbacks
Webhooks cut the latency between a task finishing and your app knowing. They are a delivery optimization: three attempts, then the gateway stops trying. The status endpoint stays the source of truth.
#Register the callback at submit time
Pass webhook_url on the submit request. Add webhook_secret and every delivery is signed — without it, nothing is.
curl https://www.aisplice.store/v1/videos \
-H "Authorization: Bearer sk-YOUR_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: video-job-8842" \
-d '{
"model": "doubao-seedance-2.5",
"prompt": "A cinematic product scene",
"webhook_url": "https://your.app/hooks/ai-tasks",
"webhook_secret": "whsec_replace_me"
}'Must be publicly routable
localhost, 127.0.0.1, private ranges, link-local and cloud metadata addresses are rejected when the task is submitted — not at delivery time. Use a tunnel while developing.
http or https, resolvable at submit time
The hostname is resolved and every returned address is checked. A name that does not resolve fails the submit.
Delivery is best-effort
Three retries, then the gateway stops and only logs. The status endpoint stays the source of truth.
localhost is rejected when the task is submitted
#Headers and payload
| Header | Sent when | What to do with it |
|---|---|---|
| X-Xiaoxiang-Signature | Only when the task was submitted with webhook_secret. | sha256=<hex HMAC-SHA256 of the raw body>. Verify before parsing, with a constant-time compare. |
| X-Xiaoxiang-Event | Every delivery. | task.completed or task.failed — route on this instead of re-deriving intent from the payload. |
| X-Xiaoxiang-Event-Id | Every delivery. | Stable across retries of the same event. Store it and drop repeats — this is your deduplication key. |
| Field | Type | Present | Param description |
|---|---|---|---|
| event_id | string | Always | Matches X-Xiaoxiang-Event-Id. Deduplicate on it. |
| event | string | Always | "task.completed" or "task.failed". |
| task_id | string | Always | The id returned by the submit call — your join key. |
| status | string | Always | Terminal status in the video vocabulary — "completed" or "failed", even for image and music tasks. |
| model | string | When known | Model that produced the task. Omitted when unknown. |
| url | string | On success | Result URL when the task succeeded. Prefer /content for a stable platform link. |
| fail_reason | string | On failure | Present on failure only. |
| quota | integer | Always | Quota actually consumed, in internal units. Reconcile against the usage log, not this field. |
| created_at | integer | Always | Unix seconds, task submission. |
| finished_at | integer | Always | Unix seconds, terminal transition. |
{
"event_id": "task_8f21c...:task.completed",
"event": "task.completed",
"task_id": "8f21c0d2-...",
"status": "completed",
"model": "doubao-seedance-2.5",
"url": "https://cdn.example.com/renders/8f21c0d2.mp4",
"quota": 128000,
"created_at": 1789000000,
"finished_at": 1789000184
}#Verify the raw request body
Compute HMAC-SHA256 over the exact bytes you received, before any JSON parsing — re-serializing the payload changes the bytes and breaks the comparison. Compare in constant time and reject on mismatch.
import crypto from "node:crypto";
// Express: express.raw({ type: "application/json" }) — not express.json().
export function handleTaskWebhook(req, res) {
const received = req.get("X-Xiaoxiang-Signature") ?? "";
const expected =
"sha256=" +
crypto.createHmac("sha256", process.env.WEBHOOK_SECRET)
.update(req.body) // Buffer of the raw body
.digest("hex");
const a = Buffer.from(received);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString("utf8"));
// Deduplicate first, acknowledge fast, then do the work off-request.
if (!seen.add(req.get("X-Xiaoxiang-Event-Id"))) return res.status(200).end();
queue.push(event);
res.status(200).end();
}#Delivery and deduplication
Acknowledge within 15 seconds
That is the delivery timeout. Anything slower counts as a failed attempt, so queue the work and return 2xx first.
Deduplicate on the event id
X-Xiaoxiang-Event-Id is stable across retries. Store it and drop repeats before touching business state.
Expect three retries, then silence
Failed deliveries retry after 5s, 30s and 2min. After that the gateway only logs — nothing else will arrive.
#Keep a reconciliation poller
Webhook delivery is best-effort
Still stuck?
Send us the request ID or the error message and we will look into it.