Webhooks
Webhooks push real-time notifications to your server whenever email events occur. Unlike SSE (which requires a persistent connection), webhooks deliver events to any publicly accessible URL via HTTP POST.
Setup
Webhooks are configured per-mailbox. There is no /v1/webhooks API. Instead, configure the webhook URL on your mailbox:
Via CLI
# Set a webhook URL for the current mailbox
mails webhook set https://your-server.com/webhooks/mails
# View current webhook configuration
mails webhook list
Via API
Set or update the webhook URL using PATCH /v1/mailbox:
curl -s -X PATCH -H "Authorization: Bearer $MAILS_API_KEY" \
-H "Content-Type: application/json" \
"https://mails-worker.genedai.workers.dev/v1/mailbox" \
-d '{
"webhook_url": "https://your-server.com/webhooks/mails"
}'
View the current webhook configuration:
curl -s -H "Authorization: Bearer $MAILS_API_KEY" \
"https://mails-worker.genedai.workers.dev/v1/mailbox"
The webhook URL is stored in the auth_tokens table's webhook_url column.
Event Types
| Event | Trigger | Description |
|---|---|---|
message.received | Inbound email arrives | Fired when a new email is received by the mailbox. |
message.delivered | Outbound email delivered | Fired when the recipient's mail server confirms delivery of your email. |
message.bounced | Outbound email bounced | Fired when delivery fails permanently (hard bounce). |
message.complained | Spam complaint received | Fired when a recipient marks your email as spam. |
Event Payload
Every webhook delivery sends a flat JSON payload. The exact fields depend on what data is available, but the core fields are always present:
{
"event": "message.received",
"email_id": "msg_def456",
"mailbox": "[email protected]",
"from": "[email protected]",
"subject": "Hello from a user",
"received_at": "2026-04-01T12:00:00Z",
"message_id": "<[email protected]>",
"thread_id": "thr_abc123",
"labels": ["personal"],
"has_attachments": false,
"attachment_count": 0
}
Request Headers
Every webhook request includes these custom headers:
| Header | Description |
|---|---|
X-Webhook-Signature | sha256=<hex> — HMAC-SHA256 of the raw request body using your WEBHOOK_SECRET |
X-Webhook-Event | The event type (e.g. message.received) |
X-Webhook-Id | The email ID associated with this event |
Signature Verification
Always verify the X-Webhook-Signature header to ensure the request is authentic.
Verification Example (Node.js)
const crypto = require('crypto');
function verifyWebhook(req, secret) {
const header = req.headers['x-webhook-signature']; // "sha256=abcdef..."
const signature = header.replace('sha256=', '');
const expected = crypto
.createHmac('sha256', secret)
.update(req.rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expected, 'hex')
);
}
// In your Express handler:
app.post('/webhooks/mails', (req, res) => {
if (!verifyWebhook(req, process.env.WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const { event, mailbox } = req.body;
console.log(`Received ${event} for ${mailbox}`);
res.status(200).json({ ok: true });
});
Verification Example (Python)
import hmac
import hashlib
def verify_webhook(body: bytes, signature_header: str, secret: str) -> bool:
# signature_header is "sha256=abcdef..."
signature = signature_header.replace("sha256=", "")
expected = hmac.new(
secret.encode(),
body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
# In your Flask handler:
@app.post('/webhooks/mails')
def handle_webhook():
signature = request.headers.get('X-Webhook-Signature', '')
if not verify_webhook(request.data, signature, WEBHOOK_SECRET):
return {'error': 'Invalid signature'}, 401
event = request.json
print(f"Received {event['event']} for {event['mailbox']}")
return {'ok': True}
Retry Behavior
If your endpoint does not return a 2xx status code (or the connection times out), mails-agent retries with up to 4 total attempts (1 initial + 3 retries) with the following delays (designed to stay within Cloudflare Worker time limits):
| Attempt | Delay | Cumulative Wait |
|---|---|---|
| 1st (initial) | 0s | 0s |
| 2nd retry | 1s | 1s |
| 3rd retry | 3s | 4s |
| 4th retry | 8s | 12s |
Total retry time is under 15 seconds to stay within Worker execution limits. After all 4 attempts fail for a single event, the failure counter increments. After 10 consecutive failures across multiple events, the webhook is automatically marked as failed and no further deliveries are attempted until the webhook is reconfigured or reset.
Best Practices
- Return 200 quickly. Process events asynchronously. Acknowledge the webhook immediately and handle the event in a background job.
- Always verify signatures. Never trust incoming webhook data without verifying the
X-Webhook-Signatureheader. - Handle duplicates. In rare cases, the same event may be delivered more than once. Use the
email_idfield to deduplicate.