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

> Be told when a twin ends: signed events, retried for a day.

Register an https URL and you receive an event each time a twin ends. Events are signed as the [Standard Webhooks](https://www.standardwebhooks.com) specification signs them, so any Standard Webhooks library verifies them.

## Endpoints

| Method and path | Description |
| - | - |
| `POST /v1/kennet/webhook_endpoints` | JSON `{"url": "https://..."}`. Answers `201` with the endpoint and its `secret` |
| `GET /v1/kennet/webhook_endpoints` | Your endpoints, newest first, without their secrets |
| `DELETE /v1/kennet/webhook_endpoints/{id}` | Stop sending to it. Its pending deliveries are dropped |

```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"}'
```

```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"],
  "secret": "whsec_..."
}
```

The `secret` is shown only once, in this response. Store it with your other secrets. To change a secret, create a new endpoint and delete the old one.

The URL must be `https`, must not carry a user or password, and its host must resolve only to public addresses. Private, loopback, link-local and cloud metadata addresses are refused (`400 webhook_url_invalid`). The host is resolved and checked again on every delivery, and redirects are not followed.

Endpoints are per organization and per mode: a test key's endpoints receive only test-mode events. Each organization may have at most 16 endpoints per mode.

## Events

| Type | When |
| - | - |
| `kennet.twin.completed` | The twin completed; its content is ready |
| `kennet.twin.failed` | The twin failed; `data.error` says why |

```json theme={"dark"}
{
  "id": "evt_4c2a...",
  "object": "event",
  "type": "kennet.twin.completed",
  "api_version": "2026-10-11",
  "created": 1760054640,
  "livemode": true,
  "data": {
    "id": "twin_t-2f9c...",
    "object": "kennet.twin",
    "status": "completed",
    "created": 1760054400,
    "completed": 1760054580,
    "filename": "trace.jsonl",
    "error": null,
    "livemode": true
  }
}
```

`data` is the [twin object](/api/twins#the-twin-object) at the moment the twin ended. An event is usually sent within a minute or two of the twin ending.

## Delivery

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

| Header | Value |
| - | - |
| `webhook-id` | The event's id. It is the same on every attempt |
| `webhook-timestamp` | Unix seconds when this attempt was signed |
| `webhook-signature` | `v1,<base64 signature>` |
| `user-agent` | `webhooks/1` |

Answer with any 2xx status within 10 seconds. Anything else, including a redirect or a timeout, counts as a failed attempt and is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 6 hours. That is 8 attempts over about 24 hours, after which the delivery is dropped.

Delivery is at least once, so you may receive the same event more than once. Deduplicate on `webhook-id`. Events can also arrive out of order. If order matters, read `data.completed`, or retrieve the twin.

## Verifying a signature

The signature is an HMAC-SHA256 over `<webhook-id>.<webhook-timestamp>.<body>`. The HMAC key is the secret with its `whsec_` prefix removed, base64-decoded. To verify:

1. Compute the signature over the raw body, before you parse it.
2. Compare it in constant time with each `v1,` entry of `webhook-signature` (entries are separated by spaces).
3. Reject a timestamp more than 5 minutes away from your clock.

<CodeGroup>
  ```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);
    });
  }
  ```
</CodeGroup>

Header names are lowercase in both examples. Normalize them if your framework doesn't.


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