Skip to main content

Mailgun Webhooks Example: Track Email Delivery & Engagement

Mailgun webhooks notify your application when email events occur — messages are delivered, opened, clicked, bounced, or marked as spam. Instead of polling the Events API, webhooks push real-time event data to your endpoint.

The problem? Setting up Mailgun webhooks requires HMAC-SHA256 signature verification, handling different content types, and processing various event formats.

The solution? Deploy a production-ready Mailgun webhook handler with Codehooks.io in under 5 minutes.

New to webhooks?

If you're new to webhooks, check out our guide on What are webhooks? to understand the fundamentals before diving into this Mailgun-specific implementation.

Prerequisites

Before you begin, sign up for a free Codehooks.io account and install the CLI:

npm install -g codehooks

You'll also need:

Quick Start: Email Event Handler

Create a new Codehooks project:

coho create my-mailgun-handler
cd my-mailgun-handler

Replace index.js with:

import { app, Datastore } from 'codehooks-js';
import crypto from 'crypto';

// Bypass JWT auth for Mailgun webhook endpoint
app.auth('/mailgun/*', (req, res, next) => {
next();
});

// Verify Mailgun webhook signature
function verifyMailgunSignature(signingKey, timestamp, token, signature) {
const encodedToken = crypto
.createHmac('sha256', signingKey)
.update(timestamp.concat(token))
.digest('hex');
return encodedToken === signature;
}

// Handle Mailgun Event Webhook
app.post('/mailgun/events', async (req, res) => {
const signingKey = process.env.MAILGUN_WEBHOOK_SIGNING_KEY;

// Extract signature data from request
const { timestamp, token, signature } = req.body.signature || req.body;

// Verify the webhook is from Mailgun
if (signingKey && timestamp && token && signature) {
const isValid = verifyMailgunSignature(signingKey, timestamp, token, signature);
if (!isValid) {
console.error('Invalid Mailgun signature');
return res.status(403).json({ error: 'Invalid signature' });
}
}

// Extract event data
const eventData = req.body['event-data'] || req.body;
const event = eventData.event;
const recipient = eventData.recipient;
const messageId = eventData.message?.headers?.['message-id'];

console.log(`Mailgun event: ${event} for ${recipient}`);

// Store the event
const conn = await Datastore.open();
await conn.insertOne('email_events', {
event,
recipient,
messageId,
timestamp: eventData.timestamp,
deliveryStatus: eventData['delivery-status'],
tags: eventData.tags || [],
receivedAt: new Date().toISOString(),
});

// Handle specific event types
switch (event) {
case 'delivered':
console.log(`Email delivered to ${recipient}`);
break;
case 'opened':
console.log(`Email opened by ${recipient}`);
break;
case 'clicked':
console.log(`Link clicked by ${recipient}: ${eventData.url}`);
break;
case 'failed':
console.log(`Email failed for ${recipient}: ${eventData.reason}`);
break;
case 'complained':
console.log(`Spam complaint from ${recipient}`);
break;
case 'unsubscribed':
console.log(`Unsubscribe from ${recipient}`);
break;
}

res.status(200).json({ received: true });
});

export default app.init();

Deploy and configure:

coho deploy
coho set-env MAILGUN_WEBHOOK_SIGNING_KEY "your-signing-key" --encrypted

Your webhook URL will be: https://my-mailgun-handler-xxxx.api.codehooks.io/dev/mailgun/events

Configure Mailgun Dashboard

  1. Go to Mailgun Dashboard → Sending → Webhooks
  2. Select your domain
  3. Click Add webhook for each event type you want to track
  4. Enter your Codehooks URL: https://your-app.api.codehooks.io/dev/mailgun/events
  5. Select the event type (delivered, opened, clicked, etc.)
  6. Click Create webhook

To get your Webhook Signing Key:

  1. Go to Settings → Webhooks
  2. Click Show next to "HTTP webhook signing key"
  3. Copy the key
coho set-env MAILGUN_WEBHOOK_SIGNING_KEY "key-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" --encrypted

Mailgun Event Types

Delivery Events

EventDescriptionKey Fields
acceptedMailgun accepted the message for deliveryrecipient, message-id
deliveredMessage delivered to recipient's serverrecipient, delivery-status
failedPermanent delivery failurerecipient, reason, severity
rejectedMessage rejected by Mailgunrecipient, reason

Engagement Events

EventDescriptionKey Fields
openedRecipient opened the emailrecipient, ip, user-agent
clickedRecipient clicked a linkrecipient, url, ip
unsubscribedRecipient unsubscribedrecipient
complainedMarked as spamrecipient

Storage Events

EventDescriptionKey Fields
storedMessage stored for retrievalstorage.url

Event Payload Examples

Delivered Event

{
"signature": {
"timestamp": "1701388800",
"token": "abc123...",
"signature": "def456..."
},
"event-data": {
"event": "delivered",
"timestamp": 1701388800.123,
"recipient": "[email protected]",
"message": {
"headers": {
"message-id": "[email protected]"
}
},
"delivery-status": {
"code": 250,
"message": "OK",
"description": "Message accepted"
},
"tags": ["newsletter", "december"]
}
}

Clicked Event

{
"signature": {
"timestamp": "1701388900",
"token": "xyz789...",
"signature": "ghi012..."
},
"event-data": {
"event": "clicked",
"timestamp": 1701388900.456,
"recipient": "[email protected]",
"url": "https://example.com/promo",
"ip": "192.168.1.1",
"client-info": {
"user-agent": "Mozilla/5.0...",
"device-type": "desktop"
}
}
}

Failed Event

{
"signature": {
"timestamp": "1701388800",
"token": "fail123...",
"signature": "sig456..."
},
"event-data": {
"event": "failed",
"timestamp": 1701388800.789,
"recipient": "[email protected]",
"reason": "bounce",
"severity": "permanent",
"delivery-status": {
"code": 550,
"message": "User not found"
}
}
}

Advanced: Track Campaign Analytics

Store detailed analytics for email campaigns:

import { app, Datastore } from 'codehooks-js';
import crypto from 'crypto';

app.auth('/mailgun/*', (req, res, next) => {
next();
});

function verifyMailgunSignature(signingKey, timestamp, token, signature) {
const encodedToken = crypto
.createHmac('sha256', signingKey)
.update(timestamp.concat(token))
.digest('hex');
return encodedToken === signature;
}

app.post('/mailgun/events', async (req, res) => {
const signingKey = process.env.MAILGUN_WEBHOOK_SIGNING_KEY;
const { timestamp, token, signature } = req.body.signature || {};

if (signingKey && timestamp && token && signature) {
if (!verifyMailgunSignature(signingKey, timestamp, token, signature)) {
return res.status(403).json({ error: 'Invalid signature' });
}
}

const eventData = req.body['event-data'] || req.body;
const conn = await Datastore.open();

// Store raw event
await conn.insertOne('email_events', {
...eventData,
receivedAt: new Date().toISOString(),
});

// Update campaign stats if tags are present
const tags = eventData.tags || [];
for (const tag of tags) {
const updateField = getUpdateField(eventData.event);
if (updateField) {
await conn.updateOne(
'campaign_stats',
{ tag },
{
$inc: { [updateField]: 1 },
$set: { lastEventAt: new Date().toISOString() }
},
{ upsert: true }
);
}
}

// Handle bounces and complaints for list hygiene
if (eventData.event === 'failed' && eventData.severity === 'permanent') {
await conn.updateOne(
'subscribers',
{ email: eventData.recipient },
{
$set: {
status: 'bounced',
bounceReason: eventData.reason,
bounceDate: new Date().toISOString(),
}
}
);
}

if (eventData.event === 'complained') {
await conn.updateOne(
'subscribers',
{ email: eventData.recipient },
{
$set: {
status: 'complained',
doNotEmail: true,
complaintDate: new Date().toISOString(),
}
}
);
}

res.status(200).json({ received: true });
});

function getUpdateField(eventType) {
const mapping = {
'accepted': 'accepted',
'delivered': 'delivered',
'opened': 'opens',
'clicked': 'clicks',
'failed': 'failures',
'complained': 'complaints',
'unsubscribed': 'unsubscribes',
};
return mapping[eventType];
}

export default app.init();

Async Processing for High Volume

For high-volume email sending, process events asynchronously:

import { app, Datastore } from 'codehooks-js';
import crypto from 'crypto';

// Worker for async event processing
app.worker('processMailgunEvent', async (req, res) => {
const eventData = req.body.payload;
const conn = await Datastore.open();

// Store event
await conn.insertOne('email_events', {
...eventData,
processedAt: new Date().toISOString(),
});

// Handle failures - update subscriber status
if (eventData.event === 'failed' && eventData.severity === 'permanent') {
await conn.updateOne(
'subscribers',
{ email: eventData.recipient },
{ $set: { status: 'bounced', bounceReason: eventData.reason } }
);
}

// Handle spam complaints
if (eventData.event === 'complained') {
await conn.updateOne(
'subscribers',
{ email: eventData.recipient },
{ $set: { status: 'complained', doNotEmail: true } }
);
}

res.end();
});

app.auth('/mailgun/*', (req, res, next) => {
next();
});

function verifyMailgunSignature(signingKey, timestamp, token, signature) {
const encodedToken = crypto
.createHmac('sha256', signingKey)
.update(timestamp.concat(token))
.digest('hex');
return encodedToken === signature;
}

app.post('/mailgun/events', async (req, res) => {
const signingKey = process.env.MAILGUN_WEBHOOK_SIGNING_KEY;
const { timestamp, token, signature } = req.body.signature || {};

if (signingKey && timestamp && token && signature) {
if (!verifyMailgunSignature(signingKey, timestamp, token, signature)) {
return res.status(403).json({ error: 'Invalid signature' });
}
}

const eventData = req.body['event-data'] || req.body;
const conn = await Datastore.open();

// Queue for async processing
await conn.enqueue('processMailgunEvent', eventData);

// Respond immediately
res.status(200).json({ queued: true });
});

export default app.init();

Mailgun Signature Verification

Mailgun signs all webhooks using HMAC-SHA256. The signature is verified by:

  1. Concatenating the timestamp and token values
  2. Computing HMAC-SHA256 using your Webhook Signing Key
  3. Comparing the result to the signature value
import crypto from 'crypto';

function verifyMailgunSignature(signingKey, timestamp, token, signature) {
const encodedToken = crypto
.createHmac('sha256', signingKey)
.update(timestamp.concat(token))
.digest('hex');
return encodedToken === signature;
}

// Usage in webhook handler
const { timestamp, token, signature } = req.body.signature;
const isValid = verifyMailgunSignature(signingKey, timestamp, token, signature);

Security tips:

  • Cache processed token values to prevent replay attacks
  • Check that timestamp is recent (within 5-10 minutes) to reject old requests

Preventing Replay Attacks

For additional security, cache tokens and reject duplicates:

import { app, Datastore } from 'codehooks-js';
import crypto from 'crypto';

app.auth('/mailgun/*', (req, res, next) => {
next();
});

async function verifyAndCheckReplay(signingKey, timestamp, token, signature) {
// Verify signature
const encodedToken = crypto
.createHmac('sha256', signingKey)
.update(timestamp.concat(token))
.digest('hex');

if (encodedToken !== signature) {
return { valid: false, reason: 'invalid_signature' };
}

// Check timestamp is recent (within 5 minutes)
const timestampAge = Math.abs(Date.now() / 1000 - parseInt(timestamp));
if (timestampAge > 300) {
return { valid: false, reason: 'timestamp_expired' };
}

// Check for replay attack
const conn = await Datastore.open();
const existing = await conn.get(`mailgun-token-${token}`, { keyspace: 'webhooks' });

if (existing) {
return { valid: false, reason: 'replay_attack' };
}

// Store token with 10-minute TTL
await conn.set(`mailgun-token-${token}`, '1', {
keyspace: 'webhooks',
ttl: 600000 // 10 minutes
});

return { valid: true };
}

app.post('/mailgun/events', async (req, res) => {
const signingKey = process.env.MAILGUN_WEBHOOK_SIGNING_KEY;
const { timestamp, token, signature } = req.body.signature || {};

if (signingKey && timestamp && token && signature) {
const result = await verifyAndCheckReplay(signingKey, timestamp, token, signature);
if (!result.valid) {
console.error('Webhook verification failed:', result.reason);
return res.status(403).json({ error: result.reason });
}
}

// Process event...
const eventData = req.body['event-data'] || req.body;
console.log(`Processing ${eventData.event} for ${eventData.recipient}`);

res.status(200).json({ received: true });
});

export default app.init();

Best Practices

1. Always Verify Signatures in Production

if (!signingKey) {
console.warn('MAILGUN_WEBHOOK_SIGNING_KEY not set - skipping verification');
} else {
const isValid = verifyMailgunSignature(signingKey, timestamp, token, signature);
if (!isValid) {
return res.status(403).json({ error: 'Invalid signature' });
}
}

2. Handle Both Payload Formats

Mailgun can send webhooks in different formats. Handle both:

// New format
const eventData = req.body['event-data'];
const signatureData = req.body.signature;

// Legacy format (fallback)
if (!eventData) {
const { event, recipient, timestamp, token, signature } = req.body;
// Process legacy format...
}

3. Clean Bounce Lists

Automatically remove bounced addresses:

if (eventData.event === 'failed' && eventData.severity === 'permanent') {
await conn.updateOne(
'subscribers',
{ email: eventData.recipient },
{ $set: { active: false, bounced: true } }
);
}

4. Respond Quickly

Return 200 immediately to prevent retries:

// Queue for processing and respond immediately
await conn.enqueue('processEvent', eventData);
res.status(200).json({ queued: true });

Why Use Codehooks.io for Mailgun Webhooks?

FeatureDIY SetupCodehooks.io
Setup timeHours5 minutes
SSL/HTTPSConfigure certificatesBuilt-in
Signature verificationManual crypto implementationBuilt-in crypto module
Database for eventsSet up separatelyBuilt-in
Key-value store (replay prevention)Set up Redis/similarBuilt-in with TTL
Async processingConfigure queuesBuilt-in workers
MonitoringSet up loggingBuilt-in logs
CostServer costsFree tier available

Mailgun Webhooks FAQ

Common questions about handling Mailgun webhooks

How do I verify Mailgun webhook signatures?
Concatenate the timestamp and token values from the request, then compute HMAC-SHA256 using your Webhook Signing Key. Compare the result to the signature value. In Node.js, use crypto.createHmac('sha256', signingKey).update(timestamp.concat(token)).digest('hex').
Where do I find my Mailgun Webhook Signing Key?
Go to Mailgun Dashboard → Settings → Webhooks, then click 'Show' next to 'HTTP webhook signing key'. This key is different from your API key and is specifically for webhook verification.
What's the difference between failed and rejected events?
A failed event means Mailgun attempted delivery but the recipient server rejected it (bounce). A rejected event means Mailgun itself rejected the message before attempting delivery, usually due to policy violations or invalid addresses.
How do I prevent replay attacks?
Cache the token value from each webhook and reject requests with duplicate tokens. Also check that the timestamp is recent (within 5-10 minutes). Use Codehooks' built-in key-value store with TTL to track seen tokens.
Does Mailgun retry failed webhooks?
Yes, Mailgun retries webhook deliveries if your server doesn't return a 2xx response. Return 200 immediately and process events asynchronously to avoid timeouts and retries.
How do I track email opens and clicks?
Enable tracking in your Mailgun domain settings. Mailgun automatically adds tracking pixels (for opens) and rewrites links (for clicks). Subscribe to 'opened' and 'clicked' events in your webhook configuration.
What content type does Mailgun send?
Mailgun can send webhooks as either application/json or application/x-www-form-urlencoded. The newer webhook format uses JSON with a nested structure (signature and event-data objects). Handle both formats for compatibility.
How do I handle unsubscribes from Mailgun webhooks?
Subscribe to the 'unsubscribed' event. When received, update your subscriber database to mark the email as unsubscribed. You should also handle 'complained' events (spam reports) by removing those addresses to maintain sender reputation.