# Guide to AgentMail Webhooks: Features and Best Practices

AgentMail gives AI agents their own email inboxes, and its webhooks tell your agent what happens to them: a new email arrives, a sent message is delivered, bounces or is marked as spam, a custom domain finishes verifying, or a calendar event is created, updated or about to start. Webhooks are how an AgentMail-based agent reacts to email in seconds instead of polling the API.

This guide covers how AgentMail webhooks are created, scoped and signed, the 20 event types and their payloads, the delivery behaviour that catches out new integrations, and the best practices for running an AgentMail receiver in production.

## What are AgentMail webhooks?

An AgentMail webhook is a URL registered against your organisation, a pod or a single inbox, with an explicit list of event types. When one of those events happens, AgentMail sends a JSON `POST` to the URL. You create webhooks in the AgentMail console or through the API and SDKs.

AgentMail uses Svix to deliver webhooks. Each webhook has its own signing secret, prefixed `whsec_`, and every request carries three headers: `svix-id`, `svix-timestamp` and `svix-signature`. The signature is a base64 HMAC-SHA256 of the message ID, the timestamp and the raw body.

AgentMail also offers WebSockets and "polling webhooks" for agents that can't expose a public URL. Only the HTTP webhooks described here push to an endpoint.

## AgentMail webhook features

| Feature | Details |
| --- | --- |
| Configuration | The console (**Webhooks > Create Webhook**) or the API and SDKs |
| Scope | Organization, pod or inbox. A webhook can list at most 10 pods and inboxes combined |
| Subscription model | A required, non-empty `event_types` list. There is no "all events" default |
| Signature headers | `svix-id`, `svix-timestamp`, `svix-signature` |
| Scheme | HMAC-SHA256 over `{svix-id}.{svix-timestamp}.{raw body}`, base64-encoded, sent as `v1,<signature>` |
| Secret | One per webhook, prefixed `whsec_`. Shown in the console at creation and returned as `secret` when you get the webhook |
| Replay window | None stated by AgentMail |
| Response window | A `2xx` within 15 seconds. Redirects count as failures |
| Retries | Immediately, then after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours |
| Auto-disable | After 5 days of failed deliveries, with no notification |
| Delivery guarantee | At least once, unordered |
| Custom headers | Write-only static headers, set with `headers` on create and rotated with `update_headers` |
| Source IPs | Not published |

## Common events

AgentMail supports 20 event types.

| Area | Events | Fires when |
| --- | --- | --- |
| Inbound mail | `message.received` | A new email is received and processed in one of your inboxes |
| Filtered inbound mail | `message.received.spam`, `message.received.blocked`, `message.received.unauthenticated` | A message is classified as spam, matches a block list, or passes none of SPF, DKIM and DMARC |
| Outbound mail | `message.sent`, `message.delivered`, `message.bounced`, `message.complained`, `message.rejected`, `message.opened` | A message is sent, delivered, bounces, draws a spam complaint, is rejected before sending, or is opened for the first time (with open tracking on) |
| Agent Armor | `message.security.completed`, `message.security.review`, `message.security.override` | Agent Armor reaches a verdict, holds a message for review, or someone releases a held message |
| Domains | `domain.verified` | A custom domain is verified and ready to send from |
| Calendar | `calendar.event.created`, `calendar.event.updated`, `calendar.event.deleted`, `calendar.event.responded`, `calendar.event.starting`, `calendar.event.ending` | A calendar event is created, changed, deleted, responded to, starts or ends |

The three filtered variants are excluded by default. You must list them in `event_types`, and the API key needs the matching `label_spam_read`, `label_blocked_read` or `label_unauthenticated_read` permission. Once subscribed, those messages are no longer sent as `message.received`. The `message.security.*` events need `message_read`, and the calendar events need `calendar_event_read`.

Every payload shares one envelope: `type` is always the string `"event"`, `event_type` names the event, `event_id` identifies it, and the event's data sits beside them under a key such as `message`, `send`, `delivery`, `bounce`, `complaint`, `reject`, `domain` or `calendar_event`. Here is AgentMail's `message.delivered` example:

```json
{
  "type": "event",
  "event_type": "message.delivered",
  "event_id": "0f7c3a2e-5b1d-4e8a-9c6f-2d4b8a1e7c35",
  "delivery": {
    "inbox_id": "inbox_456def",
    "thread_id": "thd_789ghi",
    "message_id": "<abc123@agentmail.to>",
    "timestamp": "2023-10-27T10:06:00Z",
    "recipients": ["recipient@example.com"]
  }
}

```

Route on `event_type`, not `type`, because `type` is the same on every event.

`message.received` and its variants include the email body inline only when the stored body is 64 KiB or smaller. Above that, the payload omits `text`, `html`, `extracted_text` and `extracted_html` and carries a pre-signed `body_url`, valid for one hour, with `body_expires_at`. Attachments are never inlined; you download them through the API.

## Setting up AgentMail webhooks

1. In the [AgentMail console](https://console.agentmail.to), open **Webhooks** in the sidebar and click **Create Webhook**.
2. Enter your HTTPS endpoint URL and select the event types you want.
3. Save, then copy the signing secret and store it server-side, for example as `AGENTMAIL_WEBHOOK_SECRET`.
4. Send an email to one of your inboxes, or send a test event from the console, to confirm delivery.

From code, create a webhook with the SDK:

```python
webhook = client.webhooks.create(
    url="https://example.com/webhooks/agentmail",
    event_types=["message.received", "message.delivered", "message.bounced"],
)
secret = client.webhooks.get(webhook.webhook_id).secret

```

A request without `event_types` is rejected with a `400`. On update, a non-empty `event_types` replaces the whole list, so send every type you want to keep. To scope a webhook, pass `pod_ids` or `inbox_ids`, or create it with `pods.webhooks.create` or `inboxes.webhooks.create`.

## Securing AgentMail webhooks

Read the three Svix headers, base64-decode the part of your secret after `whsec_`, compute an HMAC-SHA256 of `{svix-id}.{svix-timestamp}.{raw body}`, base64-encode it, and compare it in constant time with each `v1,` entry in `svix-signature`. AgentMail notes that verification "requires the exact request body".

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyAgentMailSignature(rawBody, headers, secret) {
  const id = headers['svix-id'];
  const timestamp = headers['svix-timestamp'];
  const signatureHeader = headers['svix-signature'];
  if (!id || !timestamp || !signatureHeader || !secret) return false;

  // Optional: AgentMail states no tolerance. If you want a freshness check, add it here, e.g.
  // if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

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

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

const app = express();

app.post('/webhooks/agentmail', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.AGENTMAIL_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyAgentMailSignature(req.body, req.headers, secret)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.event_id, route on event.event_type, then process asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac

def verify_agentmail_signature(raw_body: bytes, headers, secret: str) -> bool:
    msg_id = headers.get("svix-id")
    timestamp = headers.get("svix-timestamp")
    signature_header = headers.get("svix-signature")
    if not (msg_id and timestamp and signature_header and secret):
        return False

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed_content = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest())

    for entry in signature_header.split(" "):
        version, _, value = entry.partition(",")
        if version == "v1" and hmac.compare_digest(value.encode(), expected):
            return True
    return False

```

AgentMail's own examples use the Svix library (`svix` on npm and PyPI), which does the same work and also applies a timestamp tolerance of its own.

To check your verifier, use the secret `whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw`, `svix-id` `msg_2KWPBgLlAfxdpx2AI54pPJ85f4W`, `svix-timestamp` `1761039000` and this exact body:

```
{"type":"event","event_type":"message.delivered","event_id":"0f7c3a2e-5b1d-4e8a-9c6f-2d4b8a1e7c35"}

```

The `svix-signature` should be:

```
v1,tkdDTrscA76zIyYYYXdKZPRBVlHB/UORU3pDTSSk8iw=

```

> **Make AgentMail webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the Svix signature on every AgentMail delivery, answers within the 15-second window, and durably queues each message and calendar event before your agent sees it.

## AgentMail webhook limitations and pain points

### A disabled endpoint loses events silently

**The Problem:** Your agent stops reacting to email, and nothing tells you why.

**Why It Happens:** AgentMail gives up on an event after 8 attempts over about 27.5 hours. If every delivery to an endpoint keeps failing for 5 days, AgentMail disables it without a notification. The only sign is `enabled: false` when you get or list the webhook, and events from the disabled period are not redelivered when you turn it back on.

**Workarounds:**

* Monitor the webhook's `enabled` field and alert when it turns `false`.
* Re-enable with `"enabled": true` once the endpoint is fixed.
* Catch up from the API: list messages with `after` set to the last event you processed, or read the inbox events feed (`GET /inboxes/{inbox_id}/events`).

**How Hookdeck Can Help:** Hookdeck accepts AgentMail's deliveries as they arrive and stores them, so an outage on your side doesn't run down AgentMail's retries or disable the webhook. You replay the stored events into your agent once it's back.

### `svix-id` is the wrong dedupe key across webhooks

**The Problem:** The same email triggers your agent twice.

**Why It Happens:** Delivery is at least once, and an event can match more than one of your webhooks, for example an organisation-level webhook and an inbox-level one. `svix-id` stays the same across retries to one webhook but differs between webhooks.

**Workarounds:**

* Deduplicate on the body's `event_id`, which is the same on every retry and every webhook that receives the event.
* When reconciling messages from the API, match on `inbox_id` plus `message_id`, because one email sent to several of your inboxes is a separate message in each.

**How Hookdeck Can Help:** Hookdeck's deduplication can key on `event_id` in the body and drop repeats before they reach your service.

### Large emails arrive without a body

**The Problem:** `text` and `html` are missing from some `message.received` events.

**Why It Happens:** The body is inlined only up to 64 KiB of stored content, which includes the plain-text and HTML versions, their extracted reply content and any inline images. Larger messages get a `body_url` that expires after one hour.

**Workarounds:**

* Check for `body_url` before reading `text` or `html`.
* Fetch it promptly, or call `inboxes.messages.get` with the `inbox_id` and `message_id`, which works after the URL expires.

**How Hookdeck Can Help:** Hookdeck delivers each event to your service as soon as it's accepted and retries on failure, which keeps the time between AgentMail sending the event and your code fetching `body_url` short.

### Filtered mail never reaches a default subscription

**The Problem:** Spam and unauthenticated messages don't trigger your webhook.

**Why It Happens:** `message.received.spam`, `message.received.blocked` and `message.received.unauthenticated` are excluded unless you list them, each needs a label permission, and once subscribed those messages stop arriving as `message.received`.

**Workarounds:**

* Name the variants you want in `event_types` and grant the matching permissions.
* Route the variants explicitly in your handler.

**How Hookdeck Can Help:** Hookdeck filters can send the spam, blocked and unauthenticated variants to a different destination from regular mail, using the `event_type` field.

## Best practices

### Verify against the raw body

Use `express.raw()`, `await request.text()` in a Next.js route handler, or `await request.body()` in FastAPI. Parsing and re-serialising the JSON changes the bytes and breaks the signature.

### Route on `event_type`

The envelope's `type` is always `"event"`. Switch on `event_type`, and acknowledge types you don't handle with a `2xx`.

### Deduplicate on `event_id`

Store `event_id` before processing and skip anything you've already seen. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Order by payload timestamps

Events aren't guaranteed to arrive in order. Use the timestamps inside the event objects, such as `delivery.timestamp`, to decide which state is current.

### Acknowledge fast and process asynchronously

AgentMail's own guidance is to return `200` immediately and process the payload in the background. Anything slower than 15 seconds counts as a failure. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Keep one secret per webhook straight

Each webhook has its own `whsec_` secret. If you register several (an organisation webhook and inbox webhooks, or staging and production), store each secret against the endpoint that receives it.

## Conclusion

AgentMail signs each webhook through Svix with an HMAC-SHA256 of the `svix-id`, `svix-timestamp` and raw body, keyed with your webhook's base64-decoded `whsec_` secret. Verify against the raw bytes, route on `event_type`, deduplicate on `event_id` rather than `svix-id`, check for `body_url` on large messages, and keep an eye on the webhook's `enabled` flag, because AgentMail disables a failing endpoint without telling you.

[Hookdeck Event Gateway](https://hookdeck.com) verifies every AgentMail delivery, stores it, and retries into your agent on your schedule, so an outage on your side doesn't cost you events.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle AgentMail webhooks reliably in minutes.