# Webhooks

A webhook endpoint is an HTTPS URL you register to receive a push the moment something
happens in an inbox you own, instead of polling `GET /v1/threads/{thread}/messages`.

## The webhook endpoint object

```json
{
  "id": "whe_01k2n9v8qf3m7d5b0y6xz4rt4f",
  "object": "webhook_endpoint",
  "url": "https://agent.example.com/webhooks/ackuna",
  "event_types": ["inbox.message.received", "inbox.message.failed"],
  "status": "active",
  "created_at": "2026-08-20T14:01:00.000Z"
}
```

`status` is `active` or `disabled`. An endpoint is disabled automatically after five
consecutive dead deliveries (see [Deliveries](#list-delivery-attempts) below), and the
organization's owners are emailed when that happens, since a disabled webhook cannot
announce its own death.

## List webhook endpoints

```
GET /v1/webhook-endpoints?limit=25
Authorization: Bearer ak_...
```

Requires scope `webhooks:read`. `secret` is never included in a list or single-fetch
response.

## Register a webhook endpoint

```
POST /v1/webhook-endpoints
Authorization: Bearer ak_...
Content-Type: application/json
Idempotency-Key: register-agent-webhook-1

{"url": "https://agent.example.com/webhooks/ackuna", "event_types": ["inbox.message.received"]}
```

```json
{
  "data": {
    "id": "whe_01k2n9v8qf3m7d5b0y6xz4rt4f",
    "object": "webhook_endpoint",
    "url": "https://agent.example.com/webhooks/ackuna",
    "event_types": ["inbox.message.received"],
    "status": "active",
    "created_at": "2026-08-20T14:01:00.000Z",
    "secret": "whsec_k3n9v8qf3m7d5b0y6xz4rt1c9a2b7e4d"
  }
}
```

Requires scope `webhooks:write`. Fields:

| Field | Required | Notes |
|---|---|---|
| `url` | yes | Must be `https`, credential-free (no `user:pass@`), and resolve only to public addresses — private, loopback, link-local (including the cloud metadata address), and carrier-grade-NAT ranges are all rejected. Up to 2048 characters. |
| `event_types` | yes | Non-empty array from the [event catalogue](#event-catalogue) below. |

`secret` — prefixed `whsec_` — is returned **only** here and from
[rotate-secret](#rotate-the-signing-secret). Store it; it is never shown again — an
`Idempotency-Key` replay of this request returns the endpoint without `secret`. 403
`quota_exceeded` if the organization's webhook endpoint quota is exhausted.

## Fetch, update, or delete a webhook endpoint

```
GET /v1/webhook-endpoints/whe_01k2n9v8qf3m7d5b0y6xz4rt4f
PATCH /v1/webhook-endpoints/whe_01k2n9v8qf3m7d5b0y6xz4rt4f
DELETE /v1/webhook-endpoints/whe_01k2n9v8qf3m7d5b0y6xz4rt4f
```

`GET` and `DELETE` need `webhooks:read` and `webhooks:write` respectively; `PATCH` needs
`webhooks:write` and accepts any of `url`, `event_types`, or `status` (`active` /
`disabled`) — send only what changes:

```
PATCH /v1/webhook-endpoints/whe_01k2n9v8qf3m7d5b0y6xz4rt4f
Authorization: Bearer ak_...
Content-Type: application/json

{"status": "disabled"}
```

`DELETE` returns `{"data": null}`. Deleting an endpoint also deletes its delivery log, and deliveries still pending for it are dropped and never sent. To keep the history, `PATCH` `{"status":"disabled"}` instead. All three 404 `not_found` for an unknown or
cross-organization id.

## Rotate the signing secret

```
POST /v1/webhook-endpoints/whe_01k2n9v8qf3m7d5b0y6xz4rt4f/rotate-secret
Authorization: Bearer ak_...
Idempotency-Key: rotate-agent-webhook-secret-1
```

Requires scope `webhooks:write`. Returns the endpoint with a freshly generated `secret` —
shown once, exactly like on creation; an `Idempotency-Key` replay omits it. The previous
secret stops validating immediately.

## List delivery attempts

```
GET /v1/webhook-endpoints/whe_01k2n9v8qf3m7d5b0y6xz4rt4f/deliveries?limit=25
Authorization: Bearer ak_...
```

```json
{
  "data": [
    {
      "id": "evt_01k2n9v8qf3m7d5b0y6xz4rt9k",
      "object": "event",
      "type": "inbox.message.received",
      "status": "delivered",
      "attempts": 1,
      "last_error": null,
      "delivered_at": "2026-08-20T14:06:01.000Z",
      "created_at": "2026-08-20T14:06:00.000Z",
      "data": {
        "inbox": { "...": "inbox object, full form" },
        "thread": { "...": "thread object" },
        "message": { "...": "message object, summary form" }
      }
    }
  ],
  "next_cursor": null,
  "has_more": false
}
```

Requires scope `webhooks:read`. Ordered newest-first (most recently created first), so the
top of page one is always the most recent delivery attempt. `status` is `pending`,
`delivered`, or `dead` (all retries exhausted). `data` mirrors what was actually sent to
your endpoint for that delivery. This `id` (`evt_...`) is the delivery's one identifier: it is
what this listing shows, what [redrive](#redrive-a-dead-delivery) takes, and the `id` inside
the pushed envelope body itself; see [below](#the-delivery-request).

## Redrive a dead delivery

```
POST /v1/webhook-deliveries/evt_01k2n9v8qf3m7d5b0y6xz4rt9k/redrive
Authorization: Bearer ak_...
Idempotency-Key: redrive-evt-01k2n9-1
```

Requires scope `webhooks:write`. Resets the delivery to `pending` and re-queues it. 409
`invalid_request` if the delivery's `status` is not `dead` (only exhausted deliveries can
be redriven), or if the delivery's endpoint is disabled — re-enable the endpoint first, or
the retry would only dead-letter again.

## The delivery request

Every delivery is a `POST` to your `url` with:

```
Content-Type: application/json
X-Ackuna-Signature: t=1755701160,v1=8f3e2a1c9b7d4e6f0a2c8b1d3e5f7a9c0b2d4e6f8a1c3e5b7d9f0a2c4e6b8d1f
```

```json
{
  "id": "evt_01k2n9v8qf3m7d5b0y6xz4rt9k",
  "type": "inbox.message.received",
  "created_at": "2026-08-20T14:06:00+00:00",
  "data": {"inbox": {"...": "..."}, "thread": {"...": "..."}, "message": {"...": "..."}}
}
```

This envelope `id` is the delivery's own `evt_...` identifier, the same value
[the deliveries listing](#list-delivery-attempts) shows and
[redrive](#redrive-a-dead-delivery) takes, so a received push correlates with
`GET /v1/webhook-endpoints/{endpoint}/deliveries` on `id` alone. Retries and redrives resend
the same envelope, `id` included, so it is also the key to deduplicate on.

Verify it before trusting `data`: recompute `hash_hmac('sha256', "{t}.{raw_body}",
your_webhook_secret)` and compare to the `v1=` value in constant time. `t` is a Unix
timestamp; reject deliveries where it is far from the current time to guard against replay
of a captured request.

Non-2xx responses (or a timeout) are retried on a backoff schedule (1 min, 5 min, 30 min, 2
hours, 8 hours) for up to 6 attempts before the delivery is marked `dead`. Redirects are not
followed — respond 2xx directly at `url`.

## Event catalogue

| `type` | `data` | Fires when |
|---|---|---|
| `inbox.message.received` | `{inbox, thread, message}` | New inbound mail was ingested into one of your inboxes. `message` is summary form. Mail addressed to several of your inboxes fires once per inbox. Mail that arrives while the inbox is paused is held: this event fires for it when the inbox is resumed, once per message, dispatched in arrival order; deliveries can still arrive out of order, so order by `message.created_at`, which is the time the mail arrived. `thread` is the thread as it is at release. `message.authentication.state` is `failed` when the sender failed DMARC or the spam check, or failed SPF or DKIM with neither passing; such mail is delivered, but check it before your agent replies (see [the message object](messages)). |
| `inbox.message.failed` | `{message, reason}` | **Inbound:** mail arrived but could not be parsed or stored, and was dropped; parsing failed before a thread or sender could be determined, so `message.thread_id` and `message.from` are `null`. **Outbound:** a message you sent will not be delivered. `reason`, which the message's `error_message` repeats, says why: for example, the provider refused it, the recipient is suppressed, it could not leave within 24 hours, the inbox was paused or archived, the provider's answer was uncertain, or it was not sent after a content review (the reason is then the generic "This message was not sent after a content review."; no webhook fires while a message waits `in_review`). `message.direction` tells the two apart, and `reason` is a sentence explaining why. |
| `inbox.message.sent` | `{inbox, thread, message}` | An outbound message you sent was accepted by the mail provider. `message.provider_message_id` is set from this point on. `POST /v1/inboxes/{inbox}/messages` answers `202` with `status: "queued"`; this event, or `inbox.message.failed`, is how you learn the outcome without polling. |

Next: [Receiving domains](domains) to host inboxes on a domain you control instead of the
shared platform domain.
