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
{
"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 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"]}
{
"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 below. |
secret — prefixed whsec_ — is returned only here and from
rotate-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_...
{
"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 takes, and the id inside
the pushed envelope body itself; see below.
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
{
"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 shows and
redrive 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). |
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 to host inboxes on a domain you control instead of the shared platform domain.