Agent skill

AgentMail Webhooks Skill

Receive and verify AgentMail webhooks (email inboxes for AI agents). Use when setting up AgentMail webhook handlers, debugging Svix signature verification (svix-id, svix-timestamp, svix-signature, whsec_ secret), deduplicating on event_id, or handling events like message.received, message.sent, message.delivered, message.bounced, message.complained, message.rejected, message.opened, message.security.review, domain.verified and calendar.event.starting.

Install this skill

npx skills add hookdeck/webhook-skills --skill agentmail-webhooks


AgentMail gives AI agents their own email inboxes. It sends webhooks through Svix when mail arrives, when outbound mail is sent, delivered, bounced or complained about, when a domain is verified, and when calendar events change.

When to Use This Skill

  • How do I receive AgentMail webhooks?
  • How do I verify an AgentMail webhook signature (svix-signature)?
  • How do I trigger my agent when an email arrives (message.received)?
  • How do I handle message.bounced or message.complained for outbound mail?
  • Why is my AgentMail webhook signature verification failing?
  • Why am I processing the same AgentMail event twice?

Verification (core)

HeaderValue
svix-idMessage ID. Same across retries to one webhook, different for webhooks at the organization, pod and inbox levels
svix-timestampUnix seconds when the message was sent
svix-signatureSpace-delimited list of v1,<base64> signatures

Signed content is {svix-id}.{svix-timestamp}.{raw body}, HMAC-SHA256, base64. The key is the base64-decoded part of the secret after whsec_. AgentMail recommends the official svix library (npm and PyPI), which also enforces a 5-minute timestamp tolerance and compares in constant time.

import { Webhook } from 'svix'; // svix 2.x is ESM-only

// rawBody: the exact bytes received (express.raw() / request.text()), NOT re-serialised JSON
function verifyAgentMailWebhook(rawBody, headers) {
  const secret = process.env.AGENTMAIL_WEBHOOK_SECRET; // whsec_...
  if (!secret) throw new Error('AGENTMAIL_WEBHOOK_SECRET not set'); // fail closed → 500
  new Webhook(secret).verify(rawBody, {
    'svix-id': headers['svix-id'],
    'svix-timestamp': headers['svix-timestamp'],
    'svix-signature': headers['svix-signature'],
  }); // throws WebhookVerificationError → 400
  return JSON.parse(rawBody.toString('utf8')); // svix 2.x verify() returns nothing
}

Python: from svix.webhooks import Webhook, then Webhook(secret).verify(raw_body, headers) followed by json.loads(raw_body).

svix 2.x gotcha: verify() no longer returns the parsed payload (JS since 2.2, Python since 2.0). AgentMail's docs show const msg = wh.verify(...), which gives undefined/None on current svix. Verify, then parse the raw body yourself.

Manual verification (no SDK) is in references/verification.md.

For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.

Deduplicate on event_id, Not svix-id

Delivery is at least once. The payload's event_id is the same on every retry and on every webhook that receives the event. svix-id changes between webhooks registered at different levels (organization, pod, inbox), so it lets duplicates through when one event matches several webhooks.

{ "type": "event", "event_type": "message.received", "event_id": "c81e728d9d4c2f636f067f89cc14862c",
  "message": { "inbox_id": "inbox_456def", "message_id": "<abc123@agentmail.to>", "subject": "...", "...": "..." },
  "thread":  { "thread_id": "thd_789ghi", "message_count": 1, "...": "..." } }

The discriminator is event_type (not type, which is always "event"). Treat event_id as an opaque string; its format varies by event type.

Common Event Types

EventFired whenPayload object
message.receivedA new email arrives in an inboxmessage + thread
message.sentAn outbound message leaves AgentMailsend
message.deliveredThe recipient's mail server accepts itdelivery
message.bouncedDelivery fails (type, sub_type, recipients)bounce
message.complainedA recipient marks it as spamcomplaint
message.rejectedRejected before sending (reason)reject
message.openedFirst open of a tracked messageopen
domain.verifiedA custom domain is verifieddomain

Also: message.received.spam, message.received.blocked, message.received.unauthenticated (same shape as message.received, opt-in only), message.security.completed, message.security.review, message.security.override (Agent Armor), and calendar.event.created|updated|deleted|responded|starting|ending. See references/overview.md for all 20.

Delivery Semantics

  • Return a 2xx within 15 seconds. Redirects count as failures. Acknowledge quickly and process in the background.
  • Retries: immediately, 5s, 5m, 30m, 2h, 5h, 10h, 10h (8 attempts over ~27.5h). An endpoint failing for 5 days is disabled (enabled: false) without notification.
  • Events aren't ordered. Order by the timestamps in the payload.

Environment Variables

AGENTMAIL_WEBHOOK_SECRET=whsec_xxxxx  # Console → Webhooks, or client.webhooks.get(id).secret

Local Development

# Express / Next.js (use 8000 for FastAPI). No install or account required.
npx hookdeck-cli listen 3000 agentmail --path /webhooks/agentmail

Register the printed URL with client.webhooks.create(url=..., event_types=["message.received"]) or in the AgentMail Console. event_types is required.

Reference Materials


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Oct 28, 2026

View on GitHub →