← Back to all posts
2026-07-22 · MailAnvil Team

MailAnvil Webhooks Deep-Dive — Track Email Delivery, Bounces, and Opens in Real-Time

You sent the email. The API returned 202 Accepted. Now what?

Did it actually land in the inbox? Did it bounce? Did the recipient open it? Did they mark it as spam?

Without webhooks, you're blind after POST /v1/send. With webhooks, you have a real-time audit trail of every email's life.

Let me show you exactly how to set this up with MailAnvil.

The Problem: REST Polling Doesn't Scale

You could poll GET /v1/emails/:id every few seconds. But:

Webhooks flip the model: MailAnvil pushes events to your server. You react, not poll.

MailAnvil Webhook Event Types

Event Trigger Payload
email.delivered Email accepted by recipient's mail server email_id, to, delivered_at
email.bounced Hard or soft bounce from recipient server email_id, to, bounce_type, reason, bounced_at
email.opened Recipient opened the email (tracking pixel) email_id, to, opened_at, user_agent
email.clicked Recipient clicked a link in the email email_id, to, clicked_at, url
email.complained Recipient marked as spam email_id, to, complained_at
email.rejected MailAnvil rejected the send (invalid domain, no DKIM, etc.) email_id, to, reason, rejected_at

Step 1: Create a Webhook Endpoint

First, deploy a receiver. Here's a minimal Cloudflare Worker:

// webhook-receiver.ts — deploy with `wrangler deploy`
export default {
  async fetch(request: Request): Promise<Response> {
    // 1. Verify MailAnvil signature
    const signature = request.headers.get('X-MailAnvil-Signature');
    const body = await request.text();

    if (!verifySignature(body, signature, WEBHOOK_SECRET)) {
      return new Response('Invalid signature', { status: 401 });
    }

    // 2. Parse event
    const event = JSON.parse(body) as MailAnvilEvent;

    // 3. Route by event type
    switch (event.type) {
      case 'email.delivered':
        await handleDelivery(event);
        break;
      case 'email.bounced':
        await handleBounce(event);
        break;
      case 'email.opened':
        await handleOpen(event);
        break;
      case 'email.complained':
        await handleComplaint(event);
        break;
    }

    return new Response('OK', { status: 200 });
  }
};

Step 2: Register the Webhook with MailAnvil

curl -X POST https://api.mailanvil.com/v1/webhooks \
  -H "Authorization: Bearer re_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://webhooks.yoursaas.com/mailanvil",
    "events": ["email.delivered", "email.bounced", "email.opened", "email.complained"],
    "secret": "whsec_your_generated_secret"
  }'

Response:

{
  "id": "wh_01J3N8K4M2P5Q7R9",
  "url": "https://webhooks.yoursaas.com/mailanvil",
  "events": ["email.delivered", "email.bounced", "email.opened", "email.complained"],
  "created_at": "2026-07-22T08:00:00Z",
  "status": "active"
}

Step 3: Handle Critical Events

Bounce Handling — Auto-Suppress Hard Bounces

Hard bounces mean "this address doesn't exist." Sending again hurts your sender reputation. MailAnvil auto-suppresses after 2 hard bounces, but you should react on the first:

# bounce_handler.py
async def handle_bounce(event: dict):
    email = event['to']
    bounce_type = event['bounce_type']  # 'hard' or 'soft'
    reason = event['reason']

    if bounce_type == 'hard':
        # Immediately flag in your DB — don't send again
        await db.execute(
            "UPDATE users SET email_status = 'bounced', bounced_at = ? WHERE email = ?",
            (event['bounced_at'], email)
        )
        log.warning(f"Hard bounce: {email} — {reason}")

    elif bounce_type == 'soft':
        # Soft bounce = temporary (mailbox full, server down)
        # MailAnvil retries for 72h, then converts to hard bounce
        log.info(f"Soft bounce: {email} — will retry")

Delivery Confirmation — Tell Your Users

async function handleDelivery(event: DeliveryEvent) {
  // Update email log
  await db.updateEmailStatus(event.email_id, 'delivered', event.delivered_at);

  // Notify your app user (optional)
  await notifyUser({
    userId: event.metadata.user_id,
    message: `Your email to ${event.to} was delivered at ${event.delivered_at}`
  });
}

Step 4: Verify Webhook Signatures

Every webhook payload carries an X-MailAnvil-Signature header. Verify it to prevent spoofed events:

function verifySignature(
  body: string, 
  signature: string | null, 
  secret: string
): boolean {
  if (!signature) return false;

  const encoder = new TextEncoder();
  const key = encoder.encode(secret);

  // HMAC-SHA256
  const expected = crypto.subtle
    ? await crypto.subtle.digest('SHA-256', encoder.encode(body + secret))
    : simpleHmac(body, secret);

  return timingSafeEqual(signature, expected);
}

Step 5: Monitor Webhook Health

MailAnvil retries failed deliveries with exponential backoff (up to 3 times over 24 hours). Check your dashboard or use:

# List all webhooks
curl https://api.mailanvil.com/v1/webhooks \
  -H "Authorization: Bearer re_your_api_key"

# Get delivery stats for a webhook
curl https://api.mailanvil.com/v1/webhooks/wh_01J3N8K4M2P5Q7R9/stats \
  -H "Authorization: Bearer re_your_api_key"

Production Checklist

What MailAnvil Handles for You

You don't need webhooks for everything. MailAnvil does these automatically:

Webhooks are for your app's awareness. Let MailAnvil handle the email reliability.

Cost: Zero Extra

Webhooks are included in every plan — Free (500/mo) through Scale (1M/mo). No per-event pricing, no hidden fees. Because transactional email infrastructure should include delivery tracking.


Start building: app.mailanvil.com
Docs: docs.mailanvil.com
GitHub: github.com/richaferry/mailanvil