# 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

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](/docs/api/createGeneration). Only tasks created with it send events to that endpoint.

```json title="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

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

```json title="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" }
}
```
```json title="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"
}
```
```json title="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" }
}
```
```json title="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" }
}
```
```json title="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](/docs/api/getGeneration) 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.

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 title="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
```
```javascript title="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;
  }
}
```
```bash title="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:

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