Verifying Webhooks

Ensure webhook requests are authentically from AgentMail.

When building webhook receivers, it’s critical to verify that incoming requests actually originate from AgentMail and haven’t been tampered with. AgentMail uses Svix to deliver webhooks, which provides cryptographic signature verification.

Why Verify Webhooks?

Without verification, anyone who discovers your webhook URL could send fake requests to your endpoint, potentially causing:

  • Data manipulation: Malicious actors could trigger actions based on fake events
  • Security breaches: Spoofed messages could inject harmful data into your systems
  • Resource exhaustion: Attackers could flood your endpoint with fake requests

Always verify webhook signatures in production environments.

Getting Your Signing Secret

Each webhook endpoint has a unique signing secret that you’ll use to verify requests. You can find this secret in the AgentMail console when you create your webhook or by fetching your webhook details:

1from agentmail import AgentMail
2
3client = AgentMail()
4
5# Get webhook details including the signing secret
6webhook = client.webhooks.get(webhook_id="ep_xxx")
7
8# The secret starts with "whsec_"
9signing_secret = webhook.secret
10print(f"Signing secret: {signing_secret}")
Keep your secret safe

Store your signing secret securely in environment variables. Never commit it to version control or expose it in client-side code.

Verification Headers

Every webhook request from AgentMail includes three headers used for verification:

HeaderDescription
svix-idUnique message identifier. Same ID is used for retries of the same message.
svix-timestampUnix timestamp (seconds) when the message was sent.
svix-signatureSpace-delimited list of signatures in the format v1,<base64> (e.g., v1,abc123 v1,def456).

The easiest way to verify webhooks is using the official Svix library, which handles all the cryptographic details for you.

1import os
2from dotenv import load_dotenv
3from flask import Flask, request
4
5from svix.webhooks import Webhook, WebhookVerificationError
6
7load_dotenv()
8
9app = Flask(__name__)
10
11secret = os.environ["AGENTMAIL_WEBHOOK_SECRET"]
12
13@app.route('/webhooks', methods=['POST'])
14def webhook_handler():
15 headers = request.headers
16 payload = request.get_data()
17
18 try:
19 wh = Webhook(secret)
20 msg = wh.verify(payload, headers)
21 except WebhookVerificationError as e:
22 return ('', 400)
23
24 # Do something with the message...
25
26 return ('', 204)
27
28if __name__ == '__main__':
29 app.run(port=3000)
Raw body required

Signature verification requires the exact request body. If you’re using body-parsing middleware (like express.json()), make sure to capture the raw body before parsing, or use express.raw() for your webhook endpoint.

Testing Locally with ngrok

During development, you’ll need a way for AgentMail to reach your local server. ngrok creates a public URL that tunnels to your local machine.

Step 1: Save Your Webhook Server

Create a webhook server file:

1import os
2from dotenv import load_dotenv
3from flask import Flask, request
4
5from svix.webhooks import Webhook, WebhookVerificationError
6
7load_dotenv()
8
9app = Flask(__name__)
10
11secret = os.environ.get("AGENTMAIL_WEBHOOK_SECRET")
12
13@app.route('/webhooks', methods=['POST'])
14def webhook_handler():
15 headers = request.headers
16 payload = request.get_data()
17
18 try:
19 wh = Webhook(secret)
20 msg = wh.verify(payload, headers)
21 print(f"Received event: {msg}")
22 except WebhookVerificationError as e:
23 print(f"Verification failed: {e}")
24 return ('', 400)
25
26 # Do something with the message...
27
28 return ('', 204)
29
30if __name__ == '__main__':
31 app.run(port=3000)

Step 2: Install Dependencies and Run the Server

$pip install flask python-dotenv svix
$python webhook_server.py

You should see output like:

$ * Serving Flask app 'webhook_server'
$ * Debug mode: off
$ * Running on http://127.0.0.1:3000
$Press CTRL+C to quit

Step 3: Start ngrok

In a new terminal window, start ngrok to create a public tunnel to your local server:

$ngrok http 3000

ngrok will display a forwarding URL:

Session Status online
Account your-email@example.com (Plan: Free)
Version 3.22.1
Region United States (California) (us-cal-1)
Forwarding https://da550b82a183.ngrok.app -> http://localhost:3000

Copy the https:// forwarding URL (e.g., https://da550b82a183.ngrok.app).

Step 4: Add the URL to AgentMail Console

  1. Go to the AgentMail Console
  2. Navigate to Webhooks in the sidebar
  3. Click Create Webhook (or edit an existing one)
  4. Paste your ngrok URL with the /webhooks path: https://da550b82a183.ngrok.app/webhooks
  5. Select the events you want to receive
  6. Save the webhook
  7. Copy the signing secret and add it to your .env file:
$AGENTMAIL_WEBHOOK_SECRET=whsec_your_secret_here

Step 5: Trigger a Test Event

Send an email to one of your AgentMail inboxes, or use the console to send a test event. You should see the webhook received in your terminal:

127.0.0.1 - - [19/Jan/2026 16:57:20] "POST /webhooks HTTP/1.1" 204 -
Received event: {'event_type': 'message.received', ...}
Ready for production?

ngrok is great for local development, but for production you’ll need to deploy your webhook server to a hosting provider. See the next section for deployment options.

Deploying to Production

For production, you’ll need to deploy your webhook server to a hosting provider that gives you a stable, public HTTPS URL. We recommend Render for its simplicity and generous free tier.

Other hosting options

You can also deploy to other platforms like Railway, Fly.io, Heroku, or any cloud provider that supports Python web applications. The key requirements are a stable public HTTPS URL and the ability to set environment variables.

Best Practices

While you might skip verification during local development, always enable it in production environments. A compromised webhook endpoint can be a serious security vulnerability.

Never hardcode your signing secret. Use environment variables or a secrets manager:

1import os
2WEBHOOK_SECRET = os.environ["AGENTMAIL_WEBHOOK_SECRET"]

Troubleshooting

  • Ensure you’re using the raw request body, not a parsed/modified version
  • Check that your signing secret is correct and matches the webhook endpoint
  • Verify you’re extracting headers correctly (they’re case-insensitive)
  • Make sure the timestamp hasn’t expired (default tolerance is 5 minutes)

If headers are missing, ensure your server/framework isn’t stripping them. Some reverse proxies may need configuration to pass through custom headers.

If you’re using body-parsing middleware, make sure to access the raw body for verification. In Express, use express.raw() for your webhook route.