跳到正文
文档版本 r8
OpenAPIJSONYAML
指南 / Webhooks

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.

Markdown 文件在 Playground 试运行文档版本 r8

Set up an endpoint

  1. 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.
  2. Copy the signing secret (whsec_...). It is shown when you create the endpoint and when you rotate it.
  3. Send the endpoint's ID as webhook_endpoint_id when you create a generation. Only tasks created with it send events to that endpoint.
Request body
{
  "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

TypeSent when
generation.succeededThe task succeeded and its result is ready to download.
generation.failedThe task failed. error_code says why. No Credits are charged.
generation.canceledThe task was canceled before it started. Its hold is released.
generation.unresolvedThe task could not finish within 24 hours of acceptance. Its hold is released and nothing is charged.
generation.refundedA charged task was refunded.
generation.late_result_availableA result arrived after the task had closed. Downloading it is free.
webhook.testYou sent a test event from the console.

Request

Each event is a POST with a JSON body and the Standard Webhooks headers:

HeaderValue
Content-Typeapplication/json
webhook-idThe event's unique ID, the same as event_id in the body.
webhook-timestampUnix time in seconds when this attempt was sent.
webhook-signatureOne 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.

FieldMeaning
unitAlways credit.
estimatedThe estimate, fixed when the task was accepted. This is the amount held; the charge follows actual usage and can differ slightly from it.
reservedCredits 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.
chargedCredits 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.
refundedCredits 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.

  1. Pass the raw body exactly as received; re-serialized JSON will not match.
  2. Reject the request if verification fails.
  3. 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 None

Node.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.json

Without 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:

AttemptSent after the previous one
1at once
21 minute
32 minutes
44 minutes
58 minutes
616 minutes
732 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.

注意

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.