# Webhooks (/advanced/webhooks)

<!-- agent-signals: reading_time_min: 8 · est_tokens: 3417 · updated: 2026-09-06 -->
Related: [WebSockets](/advanced/websockets.md), [Agent safety](/advanced/safety.md), [Custom domains](/advanced/custom-domains.md), [Deliverability and warmup](/advanced/deliverability.md), [Build a multi-tenant platform](/advanced/multi-tenant.md), [AgentID public-key authentication](/advanced/agentid.md)



# Receive AgentMail events at an HTTPS endpoint

Register an HTTPS URL and AgentMail sends it a signed `POST` for every subscribed event, so an agent reacts to mail in seconds instead of polling. Use webhooks when the service has a public HTTPS endpoint, use WebSockets when it does not, and keep polling for jobs that already run on an interval.

## Do this

Deploy a receiver that verifies the Svix signature before anything else touches the payload:

```python
# pip install flask svix
import os
from flask import Flask, request
from svix.webhooks import Webhook, WebhookVerificationError

app = Flask(__name__)
verifier = Webhook(os.environ["AGENTMAIL_WEBHOOK_SECRET"])

@app.route("/webhooks", methods=["POST"])
def handle():
    try:
        verifier.verify(request.get_data(), request.headers)
    except WebhookVerificationError:
        return "", 400
    event = request.get_json()  # hand the verified event to a queue here
    return "", 200

app.run(port=3000)
```

Create the webhook, then store the returned `secret` as `AGENTMAIL_WEBHOOK_SECRET` in the receiver's environment:

```bash
curl -X POST "https://api.agentmail.to/v0/webhooks" \
  -H "Authorization: Bearer $AGENTMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://worker.example.com/webhooks",
    "event_types": ["message.received", "message.bounced"],
    "client_id": "inbound-agent-v1"
  }'
```

Return a `2xx` within 15 seconds, deduplicate on the payload's `event_id` or on `message.message_id` for inbound mail, and hand heavy work to a queue instead of running it inside the handler.

## SDK

Install: `npm install agentmail` (TypeScript), `pip install agentmail` (Python), `npm install -g agentmail-cli` (CLI).

| Operation              | TypeScript                                    | Python                                        | CLI                         |
| ---------------------- | --------------------------------------------- | --------------------------------------------- | --------------------------- |
| Create                 | `client.webhooks.create(...)`                 | `client.webhooks.create(...)`                 | `agentmail webhooks create` |
| List                   | `client.webhooks.list()`                      | `client.webhooks.list()`                      | `agentmail webhooks list`   |
| Get, includes `secret` | `client.webhooks.get("<webhook_id>")`         | `client.webhooks.get(webhook_id=...)`         | `agentmail webhooks get`    |
| Update                 | `client.webhooks.update("<webhook_id>", ...)` | `client.webhooks.update(webhook_id=..., ...)` | `agentmail webhooks update` |
| Delete                 | `client.webhooks.delete("<webhook_id>")`      | `client.webhooks.delete(webhook_id=...)`      | `agentmail webhooks delete` |
| Read header names      | `client.webhooks.getHeaders(...)`             | `client.webhooks.get_headers(...)`            | not shown                   |
| Update headers         | `client.webhooks.updateHeaders(...)`          | `client.webhooks.update_headers(...)`         | not shown                   |

Scoped variants live at `client.pods.webhooks` and `client.inboxes.webhooks` in both SDKs. Install details: `/integrations/sdks-and-cli`.

## Facts

Complete event type catalog. A webhook receives exactly the types listed in its `event_types`:

| Event type                         | Fires when                                                                                             | Payload object      |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------- |
| `message.received`                 | A received email is processed into an inbox                                                            | `message`, `thread` |
| `message.received.spam`            | A received email is classified as spam                                                                 | `message`, `thread` |
| `message.received.blocked`         | A received email matches a block list entry                                                            | `message`, `thread` |
| `message.received.unauthenticated` | A received email arrives without authentication headers                                                | `message`, `thread` |
| `message.sent`                     | A message leaves AgentMail's servers                                                                   | `send`              |
| `message.delivered`                | The recipient's mail server confirms it accepted the message                                           | `delivery`          |
| `message.bounced`                  | A sent message fails to deliver and bounces                                                            | `bounce`            |
| `message.complained`               | A recipient reports the message as spam                                                                | `complaint`         |
| `message.rejected`                 | A send is rejected before it goes out                                                                  | `reject`            |
| `message.opened`                   | A tracked message is opened for the first time. Subscribing currently returns `400`, see Not supported | `open`              |
| `domain.verified`                  | A custom domain finishes verification                                                                  | `domain`            |

* `POST https://api.agentmail.to/v0/webhooks` creates a webhook. Required: `url` (HTTPS) and `event_types` (at least one). Optional: `client_id`, `inbox_ids`, `pod_ids`, `headers`.
* Creating again with a known `client_id` returns the existing webhook unchanged and ignores any new `url` or `event_types` in the request, so creation is safe to retry.
* Limits: at most 10 inboxes and pods combined per webhook, at most 50 webhooks per organization.
* A spam-classified message fires only `message.received.spam`, never `message.received`. The blocked and unauthenticated variants replace `message.received` the same way.
* Subscribing to a restricted receive variant requires its label permission on the key: `label_spam_read` for `message.received.spam`, `label_blocked_read` for `message.received.blocked`, and `label_unauthenticated_read` for `message.received.unauthenticated`. Creating or updating without it returns `403`.
* `message.sent` fires for your own outgoing mail. An inbound agent that treats it as new work answers its own messages in a loop.
* `message.opened` fires once per message, on the first open. It requires `track_opens` on the send, a custom domain with tracking enabled, and an HTML body.
* Every delivery carries `svix-id` (unique delivery id, reused on retries), `svix-timestamp` (Unix seconds), and `svix-signature` (space-separated values, each `v1,<base64>`), signed with the webhook `secret` (prefix `whsec_`).
* The signature covers the exact raw bytes of the body, so verify before parsing. Header names match case-insensitively. A timestamp more than 5 minutes off fails verification.
* A delivery is acknowledged only by a `2xx` returned within 15 seconds. `3xx` redirects count as failures.
* Retry schedule after a failed attempt: immediately, then 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, and 10 hours after each preceding attempt. The final retry lands about 28 hours after the event, then the delivery is marked failed.
* Delivery is at least once. Retries reuse the same `svix-id` header and the same `event_id` in the payload.
* An endpoint that fails every delivery for about 5 days is disabled automatically and its `enabled` flag turns `false`.
* Payload envelope: `type` is always `"event"`, `event_type` names what happened, `event_id` identifies the event for deduplication.
* Only `message.received` and its spam, blocked, and unauthenticated variants carry the full `message` and `thread` objects. The other events carry the small stage object from the catalog: `bounce` adds `type`, `sub_type`, and per-recipient status, `reject` has a `reason`, `open` has the message ids and open `timestamp`, `domain` has `domain_id`, `status`, and DNS `records`.
* The sender appears as both `from` and `from_`. `text` and `preview` can be absent on HTML-only mail. Attachment entries are metadata only, download through the attachments call.
* When a message's stored body exceeds 64 KB the event omits `text` and `html`. Fetch the body with `GET https://api.agentmail.to/v0/inboxes/<inbox_id>/messages/<message_id>`.
* `message_id` is RFC 822 form with angle brackets. URL-encode it in hand-built paths. The CLI and SDKs encode it for you.
* Scoped routes `POST /v0/inboxes/<inbox_id>/webhooks` and `POST /v0/pods/<pod_id>/webhooks` carry the same list, get, update, delete, and header operations. An inbox-scoped webhook can change only `event_types`, a pod-scoped webhook's pod is fixed, and a scoped webhook must keep at least one inbox or pod subscription.
* `PATCH https://api.agentmail.to/v0/webhooks/<webhook_id>` replaces `event_types` wholesale. Inboxes and pods change through `add_inbox_ids`, `remove_inbox_ids`, `add_pod_ids`, and `remove_pod_ids`.
* Custom `headers` go out with every delivery. `GET /v0/webhooks/<webhook_id>/headers` returns names only in `header_names`. `PATCH /v0/webhooks/<webhook_id>/headers` sets, replaces, and removes in one atomic call, needs at least one of `headers` or `remove_headers`, and returns `204`.
* `GET https://api.agentmail.to/v0/webhooks` lists newest first with optional `limit`, `page_token`, and `ascending`. List entries omit `secret`, `GET /v0/webhooks/<webhook_id>` returns it. `DELETE /v0/webhooks/<webhook_id>` returns `204` and stops deliveries immediately.
* Keep the account bounce rate under 2 percent. A rate above 10 percent across 50 or more recent sends flags the account for review.

## Not supported

* Plain `http` URLs are rejected at creation.
* A webhook's `url` cannot be changed after creation. Create a webhook with the new URL and delete the old one.
* A retried create with an existing `client_id` does not apply a new `url` or `event_types` from the request.
* Update does not merge `event_types`, it replaces the list. An empty `event_types` list is rejected with `400`.
* Custom header values are never returned by any read, only the names come back. A lost value can only be rotated, not recovered.
* An acknowledged delivery is never sent again, and events that occur after a delete are not held for the webhook.
* There is no documented re-enable for an automatically disabled webhook. Fix the receiver, create a fresh webhook, and delete the disabled one.
* A scoped API key cannot pass `inbox_ids` or `pod_ids` outside its own scope, that returns `403`.
* `message.delivered` does not mean placement in the recipient's primary inbox, and `message.opened` does not identify which recipient of a multi-recipient message opened it.
* `message.opened` cannot be subscribed to right now: webhook create and update with `message.opened` in `event_types` return `400` (`The following event types don't exist: message.opened`) even though the type is in the catalog. Track opens through the message's `opened` label until subscriptions accept the type.

## Errors

| Error                          | HTTP status | Cause                                                                                                                                                                    | Fix                                         |
| ------------------------------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------- |
| `validation_error`             | 400         | `event_types` missing or empty, an event type that does not exist, a non-HTTPS `url`, or the same header name in both `headers` and `remove_headers`                     | Correct the named fields and resend         |
| `label_spam_read is forbidden` | 403         | Subscribing to a restricted receive variant without its label permission on the key (`label_blocked_read` for blocked, `label_unauthenticated_read` for unauthenticated) | Use a key that holds the label permission   |
| `limit_exceeded`               | 403         | The webhook would watch more than 10 inboxes and pods combined, or the organization already has 50 webhooks                                                              | Remove a webhook or narrow the subscription |
| `Webhook not found`            | 404         | The `webhook_id` does not match a webhook visible to the key, including webhooks outside a scoped key's scope                                                            | Check the id and the key's scope            |

## Verify

```bash
curl "https://api.agentmail.to/v0/webhooks" \
  -H "Authorization: Bearer $AGENTMAIL_API_KEY"
```

A `200` with a `webhooks` array entry showing your `url`, your `event_types`, and `"enabled": true` confirms the subscription is active. End to end: send a message to a subscribed inbox and confirm the receiver logs an `event_type` and `event_id`.

## Related

* `/advanced/websockets` for the same events over a persistent connection with no public URL.
* `/core/receive` for fetching messages and downloading the attachments these events announce.
* `/core/inbound-control` for the block rules behind `message.received.blocked`.
* `/advanced/deliverability` for what bounce and complaint events mean for sender reputation.
