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.bouncedormessage.complainedfor outbound mail? - Why is my AgentMail webhook signature verification failing?
- Why am I processing the same AgentMail event twice?
Verification (core)
| Header | Value |
|---|---|
svix-id | Message ID. Same across retries to one webhook, different for webhooks at the organization, pod and inbox levels |
svix-timestamp | Unix seconds when the message was sent |
svix-signature | Space-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_ 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 showconst msg = wh.verify(...), which givesundefined/Noneon 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
| Event | Fired when | Payload object |
|---|---|---|
message.received | A new email arrives in an inbox | message + thread |
message.sent | An outbound message leaves AgentMail | send |
message.delivered | The recipient's mail server accepts it | delivery |
message.bounced | Delivery fails (type, sub_type, recipients) | bounce |
message.complained | A recipient marks it as spam | complaint |
message.rejected | Rejected before sending (reason) | reject |
message.opened | First open of a tracked message | open |
domain.verified | A custom domain is verified | domain |
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
2xxwithin 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_ or in the AgentMail Console. event_types is required.
Reference Materials
- references/overview.md - All event types and payload shapes
- references/setup.md - Creating webhooks (console, SDK, CLI), scoping to pods/inboxes, getting the secret
- references/verification.md - SDK and manual verification, gotchas, debugging