Webhooks
Receive a signed POST when a task closes, instead of polling. Deliveries follow the Standard Webhooks specification, so an official library verifies them. Answer 2xx quickly, then read the task for the full result.
Set up an endpoint
- In the console, open Webhooks and add an HTTPS endpoint on port 443 that is reachable from the public internet. Pick the events it receives; by default it receives all of them.
- Copy the signing secret (
whsec_...). It is shown when you create the endpoint and when you rotate it. - Send the endpoint's ID as
webhook_endpoint_idwhen you create a generation. Only tasks created with it send events to that endpoint.
{
"model": "kling-4-0",
"input": { "prompt": "A paper boat drifting on a pond", "settings": { "duration": 5, "resolution": "720p" } },
"webhook_endpoint_id": "wh_01JD8ZC0Q4KM",
"metadata": { "client_reference": "order-1042" }
}Use Send test event in the console to receive a webhook.test event and check your handler before you go live.
Event types
| Type | Sent when |
|---|---|
generation.succeeded | The task succeeded and its result is ready to download. |
generation.failed | The task failed. error_code says why. No Credits are charged. |
generation.canceled | The task was canceled before it started. Its hold is released. |
generation.unresolved | The task could not finish within 24 hours of acceptance. Its hold is released and nothing is charged. |
generation.refunded | A charged task was refunded. |
generation.late_result_available | A result arrived after the task had closed. Downloading it is free. |
webhook.test | You sent a test event from the console. |
Request
Each event is a POST with a JSON body and the Standard Webhooks headers:
| Header | Value |
|---|---|
Content-Type | application/json |
webhook-id | The event's unique ID, the same as event_id in the body. |
webhook-timestamp | Unix time in seconds when this attempt was sent. |
webhook-signature | One or more signatures separated by spaces, each v1, followed by Base64. There are two for 24 hours after you rotate the secret. |
generation.succeeded
{
"event_id": "generation:gen_01JD8Z5QW3XR:4",
"type": "generation.succeeded",
"created_at": "2026-11-08T09:01:31Z",
"task_id": "gen_01JD8Z5QW3XR",
"version": 4,
"execution_status": "succeeded",
"delivery_status": "ready",
"billing_status": "settled",
"closed_status": "succeeded",
"late_result_available": false,
"free": false,
"price": { "unit": "credit", "estimated": "157.000", "reserved": "0.000", "charged": "157.000", "refunded": "0.000" },
"result": { "asset_id": "ast_01JD8Z9M2C4V" }
}generation.failed
{
"event_id": "generation:gen_01JD8Z7K2P9F:3",
"type": "generation.failed",
"created_at": "2026-11-08T09:03:10Z",
"task_id": "gen_01JD8Z7K2P9F",
"version": 3,
"execution_status": "failed",
"delivery_status": "pending",
"billing_status": "released",
"closed_status": "failed",
"late_result_available": false,
"free": false,
"price": { "unit": "credit", "estimated": "157.000", "reserved": "0.000", "charged": "0.000", "refunded": "0.000" },
"result": null,
"error_code": "input_moderated"
}generation.succeeded, 3 of 4 images
{
"event_id": "generation:gen_01JD8ZF6N1QA:4",
"type": "generation.succeeded",
"created_at": "2026-11-08T09:05:42Z",
"task_id": "gen_01JD8ZF6N1QA",
"version": 4,
"execution_status": "succeeded",
"delivery_status": "ready",
"billing_status": "settled",
"closed_status": "succeeded",
"late_result_available": false,
"free": false,
"price": { "unit": "credit", "estimated": "28.000", "reserved": "0.000", "charged": "21.000", "refunded": "0.000" },
"result": { "asset_id": "ast_01JD8ZG2H5TB" }
}generation.refunded
{
"event_id": "generation:gen_01JD8Z5QW3XR:5",
"type": "generation.refunded",
"created_at": "2026-11-09T14:20:05Z",
"task_id": "gen_01JD8Z5QW3XR",
"version": 5,
"execution_status": "succeeded",
"delivery_status": "ready",
"billing_status": "refunded",
"closed_status": "succeeded",
"late_result_available": false,
"free": false,
"price": { "unit": "credit", "estimated": "157.000", "reserved": "0.000", "charged": "0.000", "refunded": "157.000" },
"result": { "asset_id": "ast_01JD8Z9M2C4V" }
}webhook.test
{
"event_id": "test_01JD8ZD3W7RT",
"type": "webhook.test",
"created_at": "2026-11-08T09:00:00Z",
"test": true
}The video amounts in the examples are the Kling 4.0 720p rate of 31.4 Credits per second for 5 seconds. The image batch asked Seedream 5.0 Lite for 4 images at 7 Credits each and received 3, so it is charged for 3 and the rest of the hold returns to your balance. error_code appears only on a failed or canceled task. The body is a summary: read the task with get a generation for every output, the input and the full error.
The price object
Every generation event carries price, a snapshot taken when the event was created. Amounts are Credits, written as strings with three decimals.
| Field | Meaning |
|---|---|
unit | Always credit. |
estimated | The estimate, fixed when the task was accepted. This is the amount held; the charge follows actual usage and can differ slightly from it. |
reserved | Credits held for the task when the event was created, shown as Held in the console. It equals estimated while billing_status is held, and is 0.000 otherwise. |
charged | Credits charged, set when billing_status is settled. It follows the actual usage the model reports, so it can be a little above or below estimated. It is 0.000 in every other state, including after a refund. |
refunded | Credits a refund returned: what the task had been charged. It is 0.000 unless billing_status is refunded. Events created before this field was added leave it out; read the task for the amount. |
free is true when a result arrived after the task had closed. That result is free: nothing more is held or charged.
A generation.refunded event follows a task that already succeeded and was charged. It keeps the task's outcome and result, and its price shows charged as 0.000 and refunded as the Credits returned to your balance.
Verify the signature
Use an official Standard Webhooks library (standardwebhooks on PyPI and npm; libraries for Go, Java, Ruby, PHP, C#, Rust and Elixir exist too) with your signing secret. It checks the signature, rejects a webhook-timestamp more than 5 minutes from your clock, and accepts the request if any signature in webhook-signature matches.
- Pass the raw body exactly as received; re-serialized JSON will not match.
- Reject the request if verification fails.
- Skip the event if you already processed its
webhook-id, and answer 2xx.
Python
# pip install standardwebhooks
from standardwebhooks import Webhook, WebhookVerificationError
wh = Webhook("whsec_...") # your endpoint's signing secret
def verify(headers, raw_body: bytes):
try:
return wh.verify(raw_body, headers) # returns the parsed event
except WebhookVerificationError:
return NoneNode.js
// npm install standardwebhooks
import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.WEBHOOK_SECRET); // whsec_...
// rawBody is a Buffer of the request body as received, e.g. express.raw({ type: "application/json" }).
export function verify(headers, rawBody) {
try {
return wh.verify(rawBody, headers); // returns the parsed event
} catch {
return null;
}
}cURL
# Sign a saved body yourself and replay it to your local handler.
TS=$(date +%s)
EVENT=generation:gen_01JD8Z5QW3XR:4
KEY_HEX=$(printf '%s' "${SIGNING_SECRET#whsec_}" | base64 -d | xxd -p -c 256)
SIG=$(printf '%s.%s.' "$EVENT" "$TS" | cat - event.json \
| openssl dgst -sha256 -mac HMAC -macopt hexkey:"$KEY_HEX" -binary | base64)
curl -X POST "$YOUR_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-H "webhook-id: $EVENT" \
-H "webhook-timestamp: $TS" \
-H "webhook-signature: v1,$SIG" \
--data-binary @event.jsonWithout a library, the signature is Base64(HMAC-SHA256(key, webhook-id + "." + webhook-timestamp + "." + raw_body)), where key is the Base64-decoded part of the secret after whsec_. Compare it to each v1, entry in constant time.
Respond and retry
Answer with any 2xx status within 3 seconds. Do the work after you answer, for example in a background job. A slower answer, any other status, a redirect or a network error counts as a failed attempt.
A failed event is sent again up to 6 more times, each wait about twice the last, with up to 10% added at random:
| Attempt | Sent after the previous one |
|---|---|
| 1 | at once |
| 2 | 1 minute |
| 3 | 2 minutes |
| 4 | 4 minutes |
| 5 | 8 minutes |
| 6 | 16 minutes |
| 7 | 32 minutes |
After the seventh failed attempt the event stops and we email your organization. You can send any event again from the endpoint's delivery list in the console. A retry or a manual resend keeps the same webhook-id, event_id and body; only webhook-timestamp and the signature are new.
Note
Events can arrive more than once and out of order. Deduplicate by event_id, and ignore an event whose version is lower than one you already handled for the same task_id.
Rotate the secret
Rotating an endpoint's secret in the console issues a new secret. For the next 24 hours every attempt, including retries of earlier events, carries two signatures: one made with the new secret and one with the old. Deploy the new secret within that window; a library configured with either secret verifies the request. After 24 hours only the new secret signs.
If you rotate again within the window, the secret you had before the first rotation keeps signing until the same time, and the secret issued by the first rotation stops at once. A delivery that was already being sent when you rotated may carry only the old signatures; its retry carries both.