Gareth Wilson Gareth Wilson

Guide to AgentMail Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationThe console (Webhooks > Create Webhook) or the API and SDKs
ScopeOrganization, pod or inbox. A webhook can list at most 10 pods and inboxes combined
Subscription modelA required, non-empty event_types list. There is no "all events" default
Signature headerssvix-id, svix-timestamp, svix-signature
SchemeHMAC-SHA256 over {svix-id}.{svix-timestamp}.{raw body}, base64-encoded, sent as v1,<signature>
SecretOne per webhook, prefixed whsec_. Shown in the console at creation and returned as secret when you get the webhook
Replay windowNone stated by AgentMail
Response windowA 2xx within 15 seconds. Redirects count as failures
RetriesImmediately, then after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours
Auto-disableAfter 5 days of failed deliveries, with no notification
Delivery guaranteeAt least once, unordered
Custom headersWrite-only static headers, set with headers on create and rotated with update_headers
Source IPsNot published

Common events

AgentMail supports 20 event types.

AreaEventsFires when
Inbound mailmessage.receivedA new email is received and processed in one of your inboxes
Filtered inbound mailmessage.received.spam, message.received.blocked, message.received.unauthenticatedA message is classified as spam, matches a block list, or passes none of SPF, DKIM and DMARC
Outbound mailmessage.sent, message.delivered, message.bounced, message.complained, message.rejected, message.openedA 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 Armormessage.security.completed, message.security.review, message.security.overrideAgent Armor reaches a verdict, holds a message for review, or someone releases a held message
Domainsdomain.verifiedA custom domain is verified and ready to send from
Calendarcalendar.event.created, calendar.event.updated, calendar.event.deleted, calendar.event.responded, calendar.event.starting, calendar.event.endingA 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:

{
  "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, 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:

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".

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:

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 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.

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.

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 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 for free and handle AgentMail webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.