Completion callback
Receive a webhook when a task reaches a terminal state.
When callBackUrl is present in the create request, xbaiapi sends one POST request after the task reaches success or fail.
Payload
{
"code": 200,
"msg": "success",
"data": {
"taskId": "xb_a1b2c3...",
"model": "seedance-2.0",
"state": "success",
"resultUrl": "https://media.xbaiapi.com/video/2026/09/17/xb_a1b2c3.mp4",
"failMsg": null,
"creditsCharged": 165,
"completeTime": 1789651045,
"expireTime": 1792243000
}
}Request headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | xbaiapi-webhook/1.0 |
X-Xbaiapi-Task-Id | Same as data.taskId, useful for deduplication |
X-Xbaiapi-Timestamp | Unix time in seconds when this attempt was signed |
X-Xbaiapi-Signature | v1= followed by the hex HMAC-SHA256 signature |
Delivery rules
| Rule | Behavior |
|---|---|
| Timing | Sent only for terminal states, never for progress updates |
| Success | Any HTTP 2xx response acknowledges delivery |
| Retry | Network errors, timeouts (10 seconds), 408, 429 and 5xx responses are retried, 4 attempts total |
| No retry | Any other 4xx response stops delivery and marks the callback as failed. Fix your endpoint, then resend it from the Tasks page in the console |
| Signature | Every attempt is signed again with a fresh timestamp |
| Duplicates | Rare duplicates are possible, deduplicate by taskId |
expireTime is when the result file is deleted, 30 days after task creation. It is null for failed tasks. Download the file before then.
Return a 2xx response quickly and move slow processing to your own queue.
Verify signatures
Anyone who learns your callback URL can send it requests. Verify the signature on every callback before you act on it, so you know the request came from xbaiapi and the body was not changed.
Your signing secret is on the API keys page of the console, under Webhook signing secret. It starts with whsec_, and the whole string, prefix included, is the HMAC key. Each account has one secret, used for every task. Rotating it replaces the old secret immediately, so callbacks sent after that, retries included, carry signatures made with the new secret.
To verify a callback:
- Read the raw request body as bytes, before any JSON parsing. Parsing and re-serializing changes the bytes and breaks the signature.
- Reject the request if
X-Xbaiapi-Timestampis more than 5 minutes away from your current time. This stops an old request from being replayed later. - Compute HMAC-SHA256 over
{timestamp}.{raw body}with your secret and hex-encode it. - Compare it with the value after
v1=inX-Xbaiapi-Signatureusing a constant-time comparison. - Deduplicate by
taskId, so a replay inside the 5-minute window has no effect either.
Return a 4xx status for requests that fail verification. xbaiapi does not retry 4xx responses, so if you configured the wrong secret, update it and resend the affected callbacks from the console.
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.XBAIAPI_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 5 * 60;
function verifyXbaiapiSignature(rawBody, timestamp, signature) {
if (!timestamp || !signature?.startsWith("v1=")) return false;
const sentAt = Number(timestamp);
const now = Math.floor(Date.now() / 1000);
if (!Number.isInteger(sentAt) || Math.abs(now - sentAt) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${timestamp}.`)
.update(rawBody)
.digest();
const received = Buffer.from(signature.slice(3), "hex");
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
const app = express();
// express.raw keeps the body as a Buffer instead of parsing it
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifyXbaiapiSignature(
req.body,
req.get("X-Xbaiapi-Timestamp"),
req.get("X-Xbaiapi-Signature"),
);
if (!ok) return res.status(401).end();
const { data } = JSON.parse(req.body.toString("utf8"));
// Deduplicate by data.taskId, then hand off to your own queue
res.status(204).end();
});import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
SECRET = os.environ["XBAIAPI_WEBHOOK_SECRET"].encode() # whsec_...
TOLERANCE_SECONDS = 5 * 60
def verify_xbaiapi_signature(raw_body: bytes, timestamp: str | None, signature: str | None) -> bool:
if not timestamp or not signature or not signature.startswith("v1="):
return False
try:
sent_at = int(timestamp)
except ValueError:
return False
if abs(time.time() - sent_at) > TOLERANCE_SECONDS:
return False
expected = hmac.new(SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature[3:])
app = Flask(__name__)
@app.post("/webhook")
def webhook():
raw_body = request.get_data() # raw bytes, before JSON parsing
if not verify_xbaiapi_signature(
raw_body,
request.headers.get("X-Xbaiapi-Timestamp"),
request.headers.get("X-Xbaiapi-Signature"),
):
abort(401)
data = json.loads(raw_body)["data"]
# Deduplicate by data["taskId"], then hand off to your own queue
return "", 204For actions that move money or publish content, such as crediting a user or releasing a video, also call GET /api/v1/jobs/recordInfo with the taskId and act on the state and result it returns. The callback then only tells you when to check.