Webhooks
After configuring an endpoint on the console "Webhooks" page, task terminal states, order and balance events are pushed to your service as signed POSTs; failures retry automatically for up to 72 hours and deliveries can be replayed.
Event types
| Event | When | data highlights |
|---|---|---|
| task.completed | Video task succeeded | id, model, status, usage, billing, result_url |
| task.failed / task.timeout / task.cancelled | Task failed / timed out / was cancelled | id, status, error, billing |
| order.paid | Top-up or purchase order credited | order_no, credit_micro_usd, order_type, plan_id, pack_id, fulfilment_skipped |
| order.refunded | An operator reverses an order (top-up balance is non-refundable) | order_no, full |
| balance.low | The available balance crossed below the alert threshold (once per crossing; it fires again only after the balance recovered above the threshold) | available_micro_usd, threshold_micro_usd |
| subscription.expiring | A plan ends soon (the lead time set in notification settings; once per plan) | subscription_id, plan_name, expires_at |
| subscription.expired | A plan has ended | subscription_id, plan_name, expired_at |
| webhook.test | "Send test" clicked in the console | endpoint_id, message |
- In order.paid, fulfilment_skipped (bool) is true when the promo code on a plan or resource pack order was no longer valid by the time the payment was credited: the plan or pack was not activated and the amount paid went to the balance instead. credit_micro_usd is then the amount actually credited, which is the discounted amount paid.
Request format
Each delivery is a JSON POST; X-Event-Id stays the same across retries and replays, so de-duplicate on it.
| Field | Type | Description |
|---|---|---|
| X-Event-Id | header | Unique event identifier (e.g. task:<id>:succeeded). |
| X-Event-Type | header | Event type. |
| X-Delivery-Id | header | ID of this delivery record; a replay produces a new ID. |
| X-Timestamp | header | Send time (Unix seconds). |
| X-Signature | header | t=<unix>,v1=<hex> — HMAC_SHA256(secret, "<unix>.<body>"). Endpoint deliveries are signed with that endpoint's secret; task callback_url deliveries with the account's task callback signing secret, in the same format. |
{
"id": "task:cgt-2026…:succeeded",
"type": "task.completed",
"created_at": "2026-09-02T00:10:00Z",
"data": {
"id": "cgt-2026…", "client_request_id": "9d72…", "model": "seedance-2.0", "status": "succeeded",
"usage": {"completion_tokens": 246840, "total_tokens": 246840},
"billing": {"charged_micro_usd": 1819440, "frozen_micro_usd": 2500000},
"result_url": "https://…/video.mp4", "storage_status": "pending"
}
}Verifying the signature
Recompute the HMAC with the secret shown when the endpoint was created and reject requests whose timestamp drifts more than 5 minutes; compare with a constant-time function.
import hmac, hashlib, time
def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
ts, sig = parts["t"], parts["v1"]
if abs(time.time() - int(ts)) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{ts}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)import crypto from "node:crypto";
export function verify(secret, header, rawBody, tolerance = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > tolerance) return false;
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.`).update(rawBody).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}- Compute the signature over the raw request body (unparsed bytes).
- Consecutive endpoint failures are shown in the console; rotate the secret at any time if it leaks.
Signed task callbacks
The POST a task's callback_url receives when the task reaches a terminal state carries an X-Signature header too, in the same format as endpoint webhooks: t=<unix>,v1=hex(HMAC_SHA256(secret, "<unix>." + raw body)).
The secret is the account-wide task callback signing secret, starting with whcb_, generated automatically at the first task callback delivery. The console's Webhook page shows it in plain text once; after that only its last four characters. A lost secret can only be replaced by rotating it.
For 24 hours after a rotation the previous secret still signs: the header is then X-Signature: t=<unix>,v1=<new signature>,v1=<previous signature>, and any one v1 matching is enough. Move your receiver to the new secret and make its check go through every v1. Within those 24 hours the secret cannot be rotated again (409), so the original secret your receiver may still use does not stop working at once.
Compute the HMAC over the raw request body, compare in constant time, and reject a timestamp more than 5 minutes from the current time so a captured callback cannot be replayed.
import crypto from "node:crypto";
import express from "express";
const secret = process.env.LINK42_CALLBACK_SECRET; // whcb_…
// X-Signature: t=<unix>,v1=<hex>[,v1=<hex>] — during the 24 hours after a
// rotation it carries one v1 per secret; any one matching is enough.
function verify(header, rawBody, tolerance = 300) {
const pairs = (header ?? "").split(",").map((part) => part.trim().split("="));
const t = pairs.find(([name]) => name === "t")?.[1];
const signatures = pairs.filter(([name, value]) => name === "v1" && value).map(([, value]) => value);
if (!t || signatures.length === 0 || Math.abs(Date.now() / 1000 - Number(t)) > tolerance) return false;
const expected = Buffer.from(crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex"));
return signatures.some((signature) => signature.length === expected.length && crypto.timingSafeEqual(expected, Buffer.from(signature)));
}
const app = express();
// Keep the raw body: the signature covers the exact bytes that were sent.
app.post("/link42/callback", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.get("X-Signature"), req.body)) return res.status(401).end();
const event = JSON.parse(req.body.toString("utf8"));
console.log(event.type, event.data?.status);
res.sendStatus(200);
});import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
SECRET = os.environ["LINK42_CALLBACK_SECRET"] # whcb_…
app = Flask(__name__)
def verify(header: str, body: bytes, tolerance: int = 300) -> bool:
# X-Signature: t=<unix>,v1=<hex>[,v1=<hex>] - during the 24 hours after a
# rotation it carries one v1 per secret; any one matching is enough.
pairs = [part.strip().split("=", 1) for part in header.split(",") if "=" in part]
timestamps = [value for name, value in pairs if name == "t"]
signatures = [value for name, value in pairs if name == "v1" and value]
try:
if not timestamps or not signatures or abs(time.time() - int(timestamps[0])) > tolerance:
return False
except ValueError:
return False
expected = hmac.new(SECRET.encode(), f"{timestamps[0]}.".encode() + body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, signature) for signature in signatures)
@app.post("/link42/callback")
def callback():
# request.get_data() is the raw body the signature covers.
if not verify(request.headers.get("X-Signature", ""), request.get_data()):
abort(401)
event = request.get_json()
print(event["type"], event["data"].get("status"))
return "", 200- The signature covers the raw bytes: do not compute it after parsing or re-serializing the JSON.
- If the secret leaks, rotate it on the console's Webhook page. LINK42_CALLBACK_SECRET in the examples is just the environment variable you keep it in; name it as you like.
Retries and replays
A non-2xx response or a timeout (10 seconds by default) counts as a failure and is retried with exponential backoff of 30s, 1m, 2m … up to once every 6 hours; after 72 hours or 20 attempts the delivery is marked as abandoned. 404 / 405 / 410 are treated as permanent failures. The console "Deliveries" page shows every attempt and lets you replay manually.
An endpoint whose deliveries have been failing for more than 24 hours, with the failure count also past its threshold (50 by default), is switched off automatically and you are notified. New events for it are kept as paused rather than dropped, and go out once you switch the endpoint on again in the console. An endpoint you switch off yourself receives no new events.