> ## Documentation Index
> Fetch the complete documentation index at: https://docs.metaphi.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Webhook endpoints, event types, signature verification and retries.

A webhook endpoint receives an `event` when a twin or a batch changes status. Events are signed per the [Standard Webhooks](https://www.standardwebhooks.com) specification, so any Standard Webhooks library can verify them.

## The webhook endpoint object

```json theme={"dark"}
{
  "id": "we_8b1f...",
  "object": "webhook_endpoint",
  "url": "https://example.com/hooks/twins",
  "created": 1760054400,
  "enabled_events": ["kennet.twin.completed", "kennet.twin.failed", "kennet.twin.paused", "kennet.twin.resumed", "kennet.batch.completed", "kennet.batch.cancelled", "kennet.batch.expired", "kennet.batch.paused", "kennet.batch.resumed", "kennet.balance.low", "kennet.batch_item.failed"],
  "secret": "whsec_..."
}
```

| Field | Type | Description |
| - | - | - |
| `id` | string | Unique identifier, prefixed `we_` |
| `object` | string | `webhook_endpoint` |
| `url` | string | The URL events are sent to |
| `created` | integer | Time of creation, in Unix seconds |
| `enabled_events` | array of strings | The event types sent to this endpoint |
| `secret` | string | The signing secret, prefixed `whsec_`. Returned only on create |

## Create a webhook endpoint

`POST /v1/kennet/webhook_endpoints`

Creates a webhook endpoint. Send JSON.

| Parameter | Type | Required | Description |
| - | - | - | - |
| `url` | string | yes | An `https` URL on a public host, with no user or password. Max 2,048 characters |

```sh theme={"dark"}
curl https://api.metaphi.ai/v1/kennet/webhook_endpoints \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/hooks/twins"}'
```

**Returns** `201` with a `webhook_endpoint` object, including `secret`. Store the secret; it is not returned again. To rotate it, create a new endpoint and delete the old one.

| Status | Code | Cause |
| - | - | - |
| 400 | `webhook_url_invalid` | Not `https`, or the host resolves to a private, loopback, link-local or metadata address |
| 400 | `webhook_endpoint_limit` | The organization has 16 endpoints in this mode |
| 503 | `webhooks_unavailable` | Retry later |

Endpoints are scoped to an organization and mode. Test-mode endpoints receive only test-mode events.

## List webhook endpoints

`GET /v1/kennet/webhook_endpoints`

Lists webhook endpoints, newest first.

**Returns** `{object: "list", data, has_more, url}`, with `webhook_endpoint` objects in `data`, without `secret`.

## Delete a webhook endpoint

`DELETE /v1/kennet/webhook_endpoints/{id}`

Deletes a webhook endpoint and drops its pending deliveries.

**Returns** `{id, object: "webhook_endpoint", deleted: true}`, or `404 resource_missing`.

## The event object

```json theme={"dark"}
{
  "id": "evt_4c2a...",
  "object": "event",
  "type": "kennet.twin.completed",
  "api_version": "2026-10-11",
  "created": 1760054640,
  "livemode": true,
  "data": {
    "id": "twin_4FFTGxX_b40dXo8F-ObejckZj1DsFKPhqvJFo25CNH0",
    "object": "kennet.twin",
    "status": "completed",
    "created": 1760054400,
    "started": 1760054410,
    "completed": 1760054580,
    "filename": "trace.jsonl",
    "end_user": null,
    "batch_id": null,
    "custom_id": null,
    "error": null,
    "livemode": true,
    "progress": null
  }
}
```

| Field | Type | Description |
| - | - | - |
| `id` | string | Unique identifier, prefixed `evt_`. Equal to the `webhook-id` header |
| `object` | string | `event` |
| `type` | string | The event type |
| `api_version` | string | The API version of `data` |
| `created` | integer | Time the event was created, in Unix seconds |
| `livemode` | boolean | `false` for a test-mode event |
| `data` | object | A [`twin`](https://docs.metaphi.ai/kennet/api/twins#the-twin-object), [`batch`](https://docs.metaphi.ai/kennet/api/batches#the-batch-object), [`batch_item`](https://docs.metaphi.ai/kennet/api/batches#the-batch-item-object) or [`balance`](#the-balance-object) object, as of the event |

## Event types

| Type | Sent when | `data` |
| - | - | - |
| `kennet.twin.completed` | A twin completes. Its content is ready | `twin` |
| `kennet.twin.failed` | A twin fails. `data.error` gives the reason | `twin` |
| `kennet.twin.paused` | A twin paused because the organization ran out of credit | `twin` |
| `kennet.twin.resumed` | A paused twin resumed after credit was added | `twin` |
| `kennet.batch.completed` | Every request in a batch has ended | `batch` |
| `kennet.batch.cancelled` | A cancelled batch's running twins have ended | `batch` |
| `kennet.batch.expired` | A batch expired. See [statuses](https://docs.metaphi.ai/kennet/api/batches.md#statuses) | `batch` |
| `kennet.batch.paused` | A batch paused because the organization ran out of credit | `batch` |
| `kennet.batch.resumed` | A paused batch resumed after credit was added | `batch` |
| `kennet.balance.low` | What the organization can spend (balance plus credit limit, less twins in progress) no longer covers everything waiting to start: paused twins and batch requests not yet started. Sent once when the shortfall starts, including when a twin or batch pauses for credit. Sent again only after credit is added, the credit limit is raised, or the work it was sent for has ended and new work is waiting | `balance` |
| `kennet.batch_item.failed` | A batch request failed before its twin was created | `batch_item` |

Twins in a batch send their own `kennet.twin.completed` and `kennet.twin.failed` events. Their pauses and cancels are their batch's: `kennet.batch.paused`, `kennet.batch.resumed` and `kennet.batch.cancelled`.

### The balance object

```json theme={"dark"}
{
  "object": "balance",
  "livemode": true,
  "balance_needed_usd": 20.0,
  "waiting": { "twins": 1, "batch_requests": 1 }
}
```

| Field | Type | Description |
| - | - | - |
| `object` | string | `balance` |
| `livemode` | boolean | `false` in test mode |
| `balance_needed_usd` | number | Credit to add to start everything waiting, at one twin's price each, against what the organization can spend. This is for the whole organization; a batch's own `balance_needed_usd` covers that batch alone |
| `waiting.twins` | integer | Twins outside a batch paused for credit |
| `waiting.batch_requests` | integer | Requests not yet started, in batches still in progress or paused. Includes a batch's twins paused for credit |

## Delivery and retries

Each event is sent as a `POST` with the event as a JSON body and these headers:

| Header | Value |
| - | - |
| `webhook-id` | The event ID. Identical on every attempt |
| `webhook-timestamp` | Time this attempt was signed, in Unix seconds |
| `webhook-signature` | `v1,<base64 signature>` |
| `content-type` | `application/json` |
| `user-agent` | `webhooks/1` |

Respond with a 2xx status within 10 seconds. Any other response, a redirect or a timeout is a failed attempt. Failed attempts are retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 6 hours: 8 attempts over about 24 hours.

Delivery is at least once, in no guaranteed order. Deduplicate on `webhook-id`. For current state, retrieve the twin or batch.

## Verify a signature

The signature is an HMAC-SHA256 over `<webhook-id>.<webhook-timestamp>.<body>`, keyed by the base64-decoded secret without its `whsec_` prefix.

1. Compute the signature over the raw body, before parsing it.
2. Compare it in constant time with each `v1,` entry in `webhook-signature` (space-separated).
3. Reject timestamps more than 5 minutes from your clock.

```python Python theme={"dark"}
import base64, hashlib, hmac, time

def verify(secret: str, headers: dict, body: bytes, tolerance: int = 300) -> bool:
    msg_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    if abs(time.time() - int(timestamp)) > tolerance:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    for entry in headers["webhook-signature"].split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature, expected):
            return True
    return False
```

```javascript Node theme={"dark"}
const crypto = require("crypto");

function verify(secret, headers, body, tolerance = 300) {
  const msgId = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > tolerance) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto
    .createHmac("sha256", key)
    .update(`${msgId}.${timestamp}.`)
    .update(body) // the raw body: a Buffer or string, before JSON.parse
    .digest();
  return headers["webhook-signature"].split(" ").some((entry) => {
    const [version, signature] = entry.split(",");
    if (version !== "v1" || !signature) return false;
    const given = Buffer.from(signature, "base64");
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}
```

Both examples expect lowercase header names.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.