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.
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:
- A Mailgun account with a verified domain
- Your Webhook Signing Key from Mailgun Dashboard → Settings → Webhooks
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
- Go to Mailgun Dashboard → Sending → Webhooks
- Select your domain
- Click Add webhook for each event type you want to track
- Enter your Codehooks URL:
https://your-app.api.codehooks.io/dev/mailgun/events - Select the event type (delivered, opened, clicked, etc.)
- Click Create webhook
To get your Webhook Signing Key:
- Go to Settings → Webhooks
- Click Show next to "HTTP webhook signing key"
- Copy the key
coho set-env MAILGUN_WEBHOOK_SIGNING_KEY "key-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" --encrypted
Mailgun Event Types
Delivery Events
| Event | Description | Key Fields |
|---|---|---|
accepted | Mailgun accepted the message for delivery | recipient, message-id |
delivered | Message delivered to recipient's server | recipient, delivery-status |
failed | Permanent delivery failure | recipient, reason, severity |
rejected | Message rejected by Mailgun | recipient, reason |
Engagement Events
| Event | Description | Key Fields |
|---|---|---|
opened | Recipient opened the email | recipient, ip, user-agent |
clicked | Recipient clicked a link | recipient, url, ip |
unsubscribed | Recipient unsubscribed | recipient |
complained | Marked as spam | recipient |
Storage Events
| Event | Description | Key Fields |
|---|---|---|
stored | Message stored for retrieval | storage.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:
- Concatenating the
timestampandtokenvalues - Computing HMAC-SHA256 using your Webhook Signing Key
- Comparing the result to the
signaturevalue
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
tokenvalues to prevent replay attacks - Check that
timestampis 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?
| Feature | DIY Setup | Codehooks.io |
|---|---|---|
| Setup time | Hours | 5 minutes |
| SSL/HTTPS | Configure certificates | Built-in |
| Signature verification | Manual crypto implementation | Built-in crypto module |
| Database for events | Set up separately | Built-in |
| Key-value store (replay prevention) | Set up Redis/similar | Built-in with TTL |
| Async processing | Configure queues | Built-in workers |
| Monitoring | Set up logging | Built-in logs |
| Cost | Server costs | Free tier available |
Related Resources
- Mailgun Webhooks Documentation
- Mailgun Webhook Guide
- Codehooks.io Quick Start
- Webhook Delivery System - Build your own outgoing webhook system
- What are Webhooks? - Learn webhook fundamentals
- SendGrid Webhooks Example - Another email service integration
- Twilio Webhooks Example - SMS and voice webhooks
- Stripe Webhooks Example - Payment webhook handler
- Browse All Examples - Explore more templates and integrations
Mailgun Webhooks FAQ
Common questions about handling Mailgun webhooks
How do I verify Mailgun webhook signatures?
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?
What's the difference between failed and rejected events?
How do I prevent replay attacks?
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?
How do I track email opens and clicks?
What content type does Mailgun send?
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.