Webhooks
Configure and secure webhook endpoints to receive structured JSON payloads for every inbound email with HMAC verification and retry logic.
Prerequisites
- Inbound email address created
- HTTPS webhook endpoint
Webhooks
Webhooks are the primary method for receiving inbound email data in real-time. When an email arrives at your MERU address, we immediately POST the structured JSON payload to your webhook URL.
Webhook Basics
How Webhooks Work
- Email arrives at your MERU address (e.g.,
user123@inbound.meruhook.com
) - MERU processes the email and converts it to structured JSON
- Webhook delivered to your endpoint with complete email data
- Your application processes the email and responds with 200 OK
Webhook Requirements
- HTTPS only - All webhook URLs must use SSL/TLS
- Public accessibility - Endpoints must be reachable from the internet
- Fast response - Return 200 OK within 30 seconds
- Idempotent handling - Process duplicate deliveries gracefully
Webhook Payload
Every webhook contains structured JSON with complete email data:
{
"event_id": "evt_abc123def456",
"address_id": "addr_abc123",
"timestamp": "2025-09-05T10:30:00Z",
"envelope": {
"mail_from": "sender@example.com",
"rcpt_to": ["user123@inbound.meruhook.com"],
"helo_domain": "mail.example.com",
"remote_ip": "198.51.100.1"
},
"headers": {
"from": "John Doe <sender@example.com>",
"to": "user123@inbound.meruhook.com",
"cc": "team@example.com",
"bcc": null,
"subject": "Customer Support Request",
"date": "2025-09-05T10:29:45Z",
"message_id": "<abc123@example.com>",
"in_reply_to": "<def456@example.com>",
"references": "<def456@example.com> <ghi789@example.com>",
"reply_to": "support@example.com"
},
"content": {
"text": "Hi there,\n\nI need help with my account...",
"html": "<p>Hi there,</p><p>I need help with my account...</p>",
"size_bytes": 2048
},
"attachments": [
{
"filename": "screenshot.png",
"content_type": "image/png",
"size_bytes": 15680,
"content": "iVBORw0KGgoAAAANSUhEUgAA...",
"content_id": "img001@example.com"
}
],
"spam": {
"score": 0.3,
"verdict": "not_spam",
"details": {
"spf": "pass",
"dkim": "pass",
"dmarc": "pass"
}
},
"metadata": {
"received_at": "2025-09-05T10:30:00Z",
"processing_time_ms": 156,
"retry_count": 0
}
}
Payload Fields
Core Fields
event_id
- Unique identifier for this webhook deliveryaddress_id
- ID of the receiving MERU addresstimestamp
- When the email was processed
Envelope Data
mail_from
- SMTP envelope senderrcpt_to
- SMTP envelope recipientshelo_domain
- Sending server’s HELO/EHLO domainremote_ip
- Sending server’s IP address
Email Headers
from
,to
,cc
,bcc
- Standard email headerssubject
- Email subject linedate
- When email was sentmessage_id
- Unique message identifierin_reply_to
,references
- Threading informationreply_to
- Reply-to address
Content
text
- Plain text email bodyhtml
- HTML email bodysize_bytes
- Total email size
Attachments
filename
- Original filenamecontent_type
- MIME typesize_bytes
- Attachment sizecontent
- Base64-encoded attachment datacontent_id
- Content-ID for inline attachments
Spam Analysis
score
- Spam score (0-10, higher = more likely spam)verdict
-spam
ornot_spam
details
- SPF, DKIM, DMARC results
Webhook Security
HMAC Signature Verification
Every webhook includes an HMAC-SHA256 signature for verification:
Meru-Signature: v1,t=1693123456,s=abc123def456...
Always verify signatures to ensure webhooks are from MERU:
Choose your language
const crypto = require('crypto');
function verifyWebhook(payload, signature, secret) {
const elements = signature.split(',');
const timestamp = elements.find(el => el.startsWith('t=')).slice(2);
const sig = elements.find(el => el.startsWith('s=')).slice(2);
// Create signed payload
const signedPayload = timestamp + '.' + payload;
// Calculate expected signature
const expectedSig = crypto
.createHmac('sha256', secret)
.update(signedPayload, 'utf8')
.digest('hex');
// Compare signatures (timing-safe)
return crypto.timingSafeEqual(
Buffer.from(sig, 'hex'),
Buffer.from(expectedSig, 'hex')
);
}
// Usage in Express
app.post('/webhook', express.raw({type: 'application/json'}), (req, res) => {
const signature = req.headers['meru-signature'];
const payload = req.body.toString();
if (!verifyWebhook(payload, signature, process.env.WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
// Process verified webhook
const email = JSON.parse(payload);
console.log('Verified email:', email.headers.subject);
res.status(200).send('OK');
});
Getting Webhook Secrets
Each address has a unique webhook secret. Get it via API:
curl -H "Authorization: Bearer YOUR_API_TOKEN" \
https://api.meruhook.com/v1/addresses/addr_abc123
Response includes the secret:
{
"webhook_secret": "whsec_abc123def456..."
}
Timestamp Verification
Prevent replay attacks by checking the timestamp:
function isRecentTimestamp(signature, tolerance = 300) { // 5 minutes
const elements = signature.split(',');
const timestamp = parseInt(elements.find(el => el.startsWith('t=')).slice(2));
const now = Math.floor(Date.now() / 1000);
return Math.abs(now - timestamp) <= tolerance;
}
Implementing Webhooks
Express.js Example
const express = require('express');
const crypto = require('crypto');
const app = express();
// Use raw parser for signature verification
app.use('/webhook', express.raw({type: 'application/json'}));
app.post('/webhook', (req, res) => {
const signature = req.headers['meru-signature'];
const payload = req.body.toString();
const secret = process.env.WEBHOOK_SECRET;
// Verify signature
if (!verifyWebhook(payload, signature, secret)) {
return res.status(401).send('Invalid signature');
}
// Verify timestamp
if (!isRecentTimestamp(signature)) {
return res.status(401).send('Request too old');
}
// Process email
const email = JSON.parse(payload);
console.log(`New email: ${email.headers.subject}`);
// Your business logic here
processInboundEmail(email);
res.status(200).send('OK');
});
function processInboundEmail(email) {
// Save to database
// Send notifications
// Extract attachments
// etc.
}
Python Flask Example
import hashlib
import hmac
import time
from flask import Flask, request
app = Flask(__name__)
@app.route('/webhook', methods=['POST'])
def handle_webhook():
signature = request.headers.get('Meru-Signature')
payload = request.get_data(as_text=True)
secret = os.getenv('WEBHOOK_SECRET')
# Verify signature
if not verify_webhook(payload, signature, secret):
return 'Invalid signature', 401
# Verify timestamp
if not is_recent_timestamp(signature):
return 'Request too old', 401
# Process email
email = request.json
print(f"New email: {email['headers']['subject']}")
# Your business logic here
process_inbound_email(email)
return 'OK', 200
def process_inbound_email(email):
# Save to database
# Send notifications
# Extract attachments
# etc.
pass
Laravel Example
use Illuminate\Http\Request;
Route::post('/webhook', function (Request $request) {
$signature = $request->header('Meru-Signature');
$payload = $request->getContent();
$secret = env('WEBHOOK_SECRET');
// Verify signature
if (!verifyWebhook($payload, $signature, $secret)) {
return response('Invalid signature', 401);
}
// Verify timestamp
if (!isRecentTimestamp($signature)) {
return response('Request too old', 401);
}
// Process email
$email = $request->json()->all();
Log::info('New email: ' . $email['headers']['subject']);
// Your business logic here
ProcessInboundEmailJob::dispatch($email);
return response('OK');
});
Retry Logic
MERU automatically retries failed webhook deliveries:
Retry Schedule
- Immediate - First retry after 1 second
- 1 minute - Second retry
- 5 minutes - Third retry
- 30 minutes - Fourth retry
- 2 hours - Fifth retry
- 8 hours - Final retry
Retry Conditions
Webhooks are retried for:
- Connection errors (timeout, DNS failure)
- Server errors (5xx status codes)
- Rate limiting (429 status code)
Webhooks are not retried for:
- Client errors (4xx status codes except 429)
- Authentication errors (401, 403)
- Successful responses (2xx status codes)
Handling Retries
Make your webhook handlers idempotent using the event_id
:
const processedEvents = new Set();
app.post('/webhook', (req, res) => {
const email = JSON.parse(req.body);
const eventId = email.event_id;
// Check if already processed
if (processedEvents.has(eventId)) {
console.log(`Event ${eventId} already processed`);
return res.status(200).send('OK');
}
// Process email
processInboundEmail(email);
// Mark as processed
processedEvents.add(eventId);
res.status(200).send('OK');
});
Testing Webhooks
Local Development
Use ngrok or similar tools to expose local servers:
# Install ngrok
npm install -g ngrok
# Expose local port 3000
ngrok http 3000
# Use the HTTPS URL for webhooks
https://abc123.ngrok.io/webhook
Webhook Testing Tools
- webhook.site - Inspect webhook payloads
- requestbin.com - Debug webhook requests
- ngrok - Tunnel local development servers
Test Payload
Send test emails to trigger webhooks:
# Send email to your MERU address
echo "Test email body" | mail -s "Test Subject" user123@inbound.meruhook.com
Or use the MERU dashboard to send test webhooks.
Common Webhook Patterns
Support Ticket Creation
function processInboundEmail(email) {
// Extract customer info from sender
const customerEmail = email.envelope.mail_from;
const customer = await getOrCreateCustomer(customerEmail);
// Create support ticket
const ticket = await createTicket({
customer_id: customer.id,
subject: email.headers.subject,
description: email.content.text,
source: 'email',
attachments: email.attachments.map(att => ({
filename: att.filename,
content: att.content,
content_type: att.content_type
}))
});
// Send confirmation email
await sendConfirmation(customerEmail, ticket.id);
}
Document Processing
function processInboundEmail(email) {
// Process attachments
for (const attachment of email.attachments) {
if (attachment.content_type === 'application/pdf') {
// Save PDF for processing
const buffer = Buffer.from(attachment.content, 'base64');
await savePDF(attachment.filename, buffer);
// Queue for OCR processing
await queueForOCR(attachment.filename);
}
}
}
Reply-by-Email
function processInboundEmail(email) {
// Extract conversation ID from subject or headers
const conversationId = extractConversationId(email.headers.subject);
if (conversationId) {
// Add reply to existing conversation
await addReplyToConversation(conversationId, {
author_email: email.envelope.mail_from,
content: email.content.text,
timestamp: email.timestamp
});
} else {
// Start new conversation
await createConversation({
starter_email: email.envelope.mail_from,
subject: email.headers.subject,
content: email.content.text
});
}
}
Troubleshooting
Common Issues
Webhooks Not Received
- Check URL accessibility - Ensure your endpoint is publicly reachable
- Verify HTTPS - Webhook URLs must use SSL/TLS
- Check firewall rules - Allow inbound connections on your port
- Test with curl - Verify your endpoint responds to POST requests
Signature Verification Fails
- Check webhook secret - Ensure you’re using the correct secret
- Verify payload - Use exact payload received (including whitespace)
- Check algorithm - Must be HMAC-SHA256
- Debug signature - Log expected vs actual signatures
High Retry Rates
- Improve response times - Respond within 30 seconds
- Return proper status codes - Use 2xx for success, avoid 5xx
- Handle load - Scale your webhook infrastructure
- Monitor errors - Check application logs for failures
Monitoring Webhooks
Track webhook health in your dashboard:
- Delivery rate - Percentage of successful deliveries
- Response times - How quickly your endpoint responds
- Error rates - Failed deliveries and reasons
- Retry counts - How often webhooks are retried
Next Steps: Learn about error handling and rate limiting to build robust webhook processors.
Related Documentation
Last updated: September 5, 2025