Webhooks

5 min read

Webhooks notify your application in real-time when email events occur — deliveries, bounces, opens, clicks, and more.

How webhooks work

1. You send an email through MailingAPI
2. Email is delivered (or bounces, gets opened, etc.)
3. We send a POST request to your webhook URL
4. Your server processes the event

Instead of polling our API, you receive instant notifications.

Creating a webhook

Via Dashboard

  1. Go to Settings → Webhooks
  2. Click Create webhook
  3. Enter your endpoint URL
  4. Select events to receive
  5. Click Create

Via API

curl -X POST https://api.mailingapi.com/v1/webhooks \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.com/webhooks/mailingapi",
    "events": ["delivered", "bounced", "complained", "opened", "clicked"]
  }'

Response:

{
  "id": "wh_abc123",
  "url": "https://yourapp.com/webhooks/mailingapi",
  "events": ["delivered", "bounced", "complained", "opened", "clicked"],
  "secret": "whsec_xyz789...",
  "status": "active"
}

Important: Save the secret — you’ll need it to verify webhook signatures.

Event types

Event When it fires
delivered Email accepted by recipient’s mail server
bounced Email rejected (permanent or temporary)
deferred Temporary delivery failure, will retry
opened Recipient opened the email (tracking pixel)
clicked Recipient clicked a link
unsubscribed Recipient clicked unsubscribe link
complained Recipient marked as spam
inbound Received inbound email

Event payload

All events follow this structure:

{
  "id": "evt_1234567890",
  "type": "delivered",
  "timestamp": "2024-01-15T10:30:00Z",
  "data": {
    "message_id": "msg_abc123",
    "from": "hello@yourdomain.com",
    "to": "user@example.com",
    "subject": "Your order has shipped",
    "metadata": {
      "user_id": "usr_123",
      "order_id": "ord_456"
    }
  }
}

Delivered event

{
  "type": "delivered",
  "data": {
    "message_id": "msg_abc123",
    "to": "user@example.com",
    "delivered_at": "2024-01-15T10:30:05Z",
    "smtp_response": "250 OK"
  }
}

Bounced event

{
  "type": "bounced",
  "data": {
    "message_id": "msg_abc123",
    "to": "invalid@example.com",
    "bounce_type": "hard",
    "bounce_code": "550",
    "bounce_message": "User not found",
    "bounced_at": "2024-01-15T10:30:02Z"
  }
}

Bounce types:

  • hard — Permanent failure (remove from list)
  • soft — Temporary failure (will retry)

Opened event

{
  "type": "opened",
  "data": {
    "message_id": "msg_abc123",
    "to": "user@example.com",
    "opened_at": "2024-01-15T11:45:00Z",
    "user_agent": "Mozilla/5.0...",
    "ip_address": "203.0.113.1"
  }
}

Clicked event

{
  "type": "clicked",
  "data": {
    "message_id": "msg_abc123",
    "to": "user@example.com",
    "clicked_at": "2024-01-15T11:46:30Z",
    "url": "https://yourdomain.com/order/123",
    "user_agent": "Mozilla/5.0...",
    "ip_address": "203.0.113.1"
  }
}

Complained event

{
  "type": "complained",
  "data": {
    "message_id": "msg_abc123",
    "to": "user@example.com",
    "complained_at": "2024-01-15T12:00:00Z",
    "feedback_type": "abuse"
  }
}

Important: Always unsubscribe users who complain to protect your reputation.

Inbound received event

Fired when an inbound email is processed by a route with webhook action.

{
  "type": "inbound.received",
  "data": {
    "message_id": "im_abc123",
    "route_id": "ir_xyz789",
    "from": "sender@example.com",
    "to": ["support@yourdomain.com"],
    "subject": "Help with my order",
    "text": "I need help with order #12345...",
    "html": "<p>I need help with order #12345...</p>",
    "headers": {
      "message-id": "<abc@example.com>",
      "date": "Mon, 20 Jan 2024 15:30:00 +0000"
    },
    "authentication": {
      "spf": "pass",
      "dkim": "pass",
      "dmarc": "pass"
    },
    "spam_score": 1.2,
    "attachments": [
      {
        "filename": "screenshot.png",
        "content_type": "image/png",
        "size": 45230
      }
    ],
    "received_at": "2024-01-20T15:30:00Z"
  }
}

Attachment content is included as base64 if the route has include_attachments: true in its action config.

Verifying signatures

Every webhook request includes a signature header. Verify it to ensure the request came from MailingAPI.

Signature header

X-MailingAPI-Signature: t=1705315800,v1=5d2a...

Components:

  • t — Unix timestamp when signature was created
  • v1 — HMAC-SHA256 signature

Verification process

  1. Extract timestamp and signature from header
  2. Build the signed payload: {timestamp}.{request_body}
  3. Compute HMAC-SHA256 using your webhook secret
  4. Compare with the provided signature

Python example

import hmac
import hashlib
import time

def verify_webhook(payload: bytes, signature_header: str, secret: str) -> bool:
    # Parse header
    parts = dict(p.split("=") for p in signature_header.split(","))
    timestamp = parts["t"]
    signature = parts["v1"]

    # Check timestamp (prevent replay attacks)
    if abs(time.time() - int(timestamp)) > 300:  # 5 minutes
        return False

    # Compute expected signature
    signed_payload = f"{timestamp}.{payload.decode()}"
    expected = hmac.new(
        secret.encode(),
        signed_payload.encode(),
        hashlib.sha256
    ).hexdigest()

    # Compare signatures
    return hmac.compare_digest(signature, expected)

# In your webhook handler
@app.post("/webhooks/mailingapi")
def handle_webhook(request):
    signature = request.headers.get("X-MailingAPI-Signature")
    if not verify_webhook(request.body, signature, WEBHOOK_SECRET):
        return Response(status_code=401)

    event = request.json()
    # Process event...
    return Response(status_code=200)

Node.js example

const crypto = require('crypto');

function verifyWebhook(payload, signatureHeader, secret) {
  const parts = Object.fromEntries(
    signatureHeader.split(',').map(p => p.split('='))
  );
  const timestamp = parts.t;
  const signature = parts.v1;

  // Check timestamp
  if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) {
    return false;
  }

  // Compute expected signature
  const signedPayload = `${timestamp}.${payload}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  // Compare signatures
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

// In your handler
app.post('/webhooks/mailingapi', (req, res) => {
  const signature = req.headers['x-mailingapi-signature'];
  if (!verifyWebhook(JSON.stringify(req.body), signature, WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }

  const event = req.body;
  // Process event...
  res.status(200).send('OK');
});

Handling webhooks

Respond quickly

Your endpoint should return 200 OK within 30 seconds. Process events asynchronously:

@app.post("/webhooks/mailingapi")
async def handle_webhook(request):
    event = request.json()

    # Queue for async processing
    await queue.enqueue("process_email_event", event)

    # Return immediately
    return Response(status_code=200)

Idempotency

Webhooks may be delivered more than once. Use the event id to deduplicate:

async def process_event(event):
    # Check if already processed
    if await cache.exists(f"event:{event['id']}"):
        return

    # Process event
    await handle_event(event)

    # Mark as processed (with TTL)
    await cache.set(f"event:{event['id']}", "1", ex=86400)

Error handling

If your endpoint returns an error (non-2xx status), we retry with exponential backoff:

Attempt Delay
1 Immediate
2 1 minute
3 5 minutes
4 30 minutes
5 2 hours
Up to 10 retries

After all retries fail, the webhook is marked as failed.

Webhook configuration

Update events

curl -X PATCH https://api.mailingapi.com/v1/webhooks/wh_abc123 \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"events": ["delivered", "bounced"]}'

Disable temporarily

curl -X PATCH https://api.mailingapi.com/v1/webhooks/wh_abc123 \
  -H "Authorization: Bearer $API_KEY" \
  -d '{"status": "paused"}'

Delete webhook

curl -X DELETE https://api.mailingapi.com/v1/webhooks/wh_abc123 \
  -H "Authorization: Bearer $API_KEY"

Testing webhooks

Using Dashboard

  1. Go to Settings → Webhooks → [Your Webhook]
  2. Click Send test event
  3. Select event type
  4. Click Send

Using CLI tools

# Local testing with ngrok
ngrok http 3000

# Then update webhook URL to ngrok URL

Best practices

  1. Always verify signatures — Prevent spoofed requests
  2. Respond quickly — Process async, return immediately
  3. Handle duplicates — Webhooks may retry
  4. Monitor failures — Set up alerts for failed deliveries
  5. Use HTTPS — We only send to secure endpoints
  6. Log everything — Helps with debugging

Troubleshooting

Webhook not receiving events

  • Verify URL is publicly accessible
  • Check HTTPS certificate is valid
  • Ensure firewall allows our IPs
  • Check webhook status is “active”

Signature verification failing

  • Ensure you’re using the raw request body
  • Check webhook secret matches
  • Verify timestamp tolerance (we use 5 minutes)

Events arriving late

  • Check your server response time
  • Look for retry patterns (indicates previous failures)
  • Verify async processing isn’t backing up

Next steps