# Receive webhooks

> Get incoming mail, sends, and approval results pushed to your server. Webhooks are managed through the API only, and every request is signed.

Webhooks have no console screen; you register and manage them through the API. Register them per agent.

## Register an endpoint

```http
POST /v1/agents/{agentId}/webhook-endpoints
```

Needs the **Read** (`messages:read`) scope.

```json
{ "url": "https://example.com/atmark-webhook", "event_types": ["message.received", "message.bounced"] }
```

- `url` must be an `https` address. Private network addresses are refused (`400 invalid_webhook_url`).
- Without `event_types`, you get the default four (`approval.resolved`, `approval.expired`, `message.sent`, `message.received`).
- The subscription can't be changed after registration. To change it, register a new endpoint.

The `201` response includes the **secret** (`whsec_…`) exactly once. It can't be fetched again, so store it right away. If you lose it, register a new endpoint.

Your plan sets how many active endpoints an agent can have (`409 webhook_endpoint_limit`). The number of registrations per day is limited too (`409 webhook_endpoint_registration_limit`).

## List and delete

```http
GET    /v1/agents/{agentId}/webhook-endpoints
DELETE /v1/agents/{agentId}/webhook-endpoints/{endpointId}
```

The list never includes secrets. Deleting needs the **Send** (`messages:send`) scope; nothing more is sent to a deleted endpoint, and it can't be restored.

## Events

| Event | When | `data` |
|---|---|---|
| `message.received` | Mail reached the agent (quarantined mail excluded) | `message_id`, `thread_id`, `received_at` |
| `message.sent` | Mail went out | `message_id`, `sent_at` |
| `approval.resolved` | An approval request was approved or rejected | `approval_id`, `decision`, `resolved_at`, `message_ids` |
| `approval.expired` | An approval deadline passed | `approval_id`, `expired_at`, `message_ids` |
| `message.delivered` | The recipient's mail server accepted it (subscribe to receive) | `message_id`, `delivered_at`, `recipient_positions` |
| `message.bounced` | It bounced (subscribe to receive) | `message_id`, `bounced_at`, `recipient_positions`, `bounce_type` (`permanent`, `transient`, `undetermined`) |
| `message.complained` | A recipient reported it as spam (subscribe to receive) | `message_id`, `complained_at`, `recipient_positions` |

The request body looks like this.

```json
{
  "id": "…",
  "type": "message.received",
  "api_version": "2026-09-23",
  "created_at": "2026-09-27T09:00:00.000Z",
  "agent_id": "7c21…f5a1",
  "data": { "message_id": "…", "thread_id": "…", "received_at": "2026-09-27T09:00:00.000Z" }
}
```

Webhooks never include the sender, subject, body, or recipient addresses. If you need them, call the [received mail API](https://docs.atmark.ai/en/api/inbound) with `message_id`. `recipient_positions` are positions (from 0) in that email's recipient list, after lowercasing the addresses, removing duplicates, and sorting by Unicode code point. Sort your own recipient list the same way to map positions back to addresses. Values are ascending and unique.

## Verify the signature

Every request carries three headers (Standard Webhooks format).

| Header | Value |
|---|---|
| `webhook-id` | The event ID. Use it to drop duplicates. |
| `webhook-timestamp` | When it was sent (Unix seconds) |
| `webhook-signature` | `v1,<base64 signature>`. Several are separated by spaces. |

The signature is an HMAC-SHA256 of `<webhook-id>.<webhook-timestamp>.<body>` with your secret. The part of the secret after `whsec_` is base64.

```js title="verify-webhook.mjs"
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyWebhook(secret, headers, rawBody) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatures = headers['webhook-signature'];
  if (!id || !timestamp || !signatures) return false;
  const ts = Number(timestamp);
  if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest();

  return signatures.split(' ').some((entry) => {
    const [version, value] = entry.split(',');
    if (version !== 'v1' || !value) return false;
    const given = Buffer.from(value, 'base64');
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}
```

Use the raw body exactly as received, before parsing. Accepting only timestamps within 5 minutes blocks replays.

## Delivery contract

- Respond with `2xx` within 10 seconds. Do heavy work after acknowledging.
- Anything other than `2xx`, a timeout, or a connection failure is a failure. Redirects (`3xx`) aren't followed and count as failures.
- Failed deliveries are retried after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 2 hours, 4 hours, 8 hours, and 16 hours: 10 attempts in total, over about 31 hours 40 minutes.
- Retries keep the same event ID and body with a fresh signature time. Drop duplicates by `webhook-id`.
- If the destination is rejected at delivery time (a domain that resolves to a private address, an invalid URL, or a deleted endpoint), there are no retries and the delivery goes straight to `dead`.
- A delivery that never succeeds also ends as `dead`.

## Delivery records and redelivery

```http
GET  /v1/agents/{agentId}/webhook-deliveries?status=dead
POST /v1/agents/{agentId}/webhook-deliveries/{deliveryId}/redeliver
```

The list (**Read** scope) takes `status` (`pending`, `delivered`, `dead`), `limit` (1–100), and `cursor`. Each item has `id`, `event_id`, `event_type`, `endpoint_id`, `status`, `attempt_count`, `last_error`, `last_http_status`, `redelivery_of`, `created_at`, `next_attempt_at` (only while `pending`), `delivered_at`, and `dead_at`. Bodies and URLs aren't in the list.

Redelivery (**Send** scope) only works for `dead` deliveries and creates a new delivery with the same event ID and body (`201`). One delivery can be redelivered up to 3 times, and an agent can make up to 100 redeliveries per hour.

## Errors

| Status | `error` | Meaning |
|---|---|---|
| `400` | `invalid_webhook_url` | The URL can't be registered. |
| `404` | `not_found` | The endpoint or delivery doesn't exist (delete, redelivery). |
| `409` | `webhook_endpoint_limit` | The agent's active endpoint limit was reached. |
| `409` | `webhook_endpoint_registration_limit` | The daily registration limit was reached. |
| `409` | `delivery_not_dead` | Only `dead` deliveries can be redelivered. |
| `409` | `webhook_endpoint_deleted` | Deliveries to a deleted endpoint can't be redelivered. |
| `409` | `redelivery_destination_rejected` | It died because the destination was rejected. Register a new endpoint with a corrected URL. |
| `409` | `delivery_already_delivered` | This delivery (or a redelivery of it) was already received. |
| `409` | `redelivery_limit` | This delivery's redeliveries are used up. |
| `429` | `redelivery_rate_limited` | The hourly redelivery limit was reached. Try again later. |

---

Source: https://docs.atmark.ai/en/api/webhooks · Last updated 2026-09-27
