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:

PATCH/v1/mailbox — Set webhook URL
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:

GET/v1/mailbox — Get mailbox info including webhook URL
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

EventTriggerDescription
message.receivedInbound email arrivesFired when a new email is received by the mailbox.
message.deliveredOutbound email deliveredFired when the recipient's mail server confirms delivery of your email.
message.bouncedOutbound email bouncedFired when delivery fails permanently (hard bounce).
message.complainedSpam complaint receivedFired 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:

HeaderDescription
X-Webhook-Signaturesha256=<hex> — HMAC-SHA256 of the raw request body using your WEBHOOK_SECRET
X-Webhook-EventThe event type (e.g. message.received)
X-Webhook-IdThe 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):

AttemptDelayCumulative Wait
1st (initial)0s0s
2nd retry1s1s
3rd retry3s4s
4th retry8s12s

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