Webhooks

MERU Documentation - Integration

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

  1. Email arrives at your MERU address (e.g., user123@inbound.meruhook.com)
  2. MERU processes the email and converts it to structured JSON
  3. Webhook delivered to your endpoint with complete email data
  4. 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 delivery
  • address_id - ID of the receiving MERU address
  • timestamp - When the email was processed

Envelope Data

  • mail_from - SMTP envelope sender
  • rcpt_to - SMTP envelope recipients
  • helo_domain - Sending server’s HELO/EHLO domain
  • remote_ip - Sending server’s IP address

Email Headers

  • from, to, cc, bcc - Standard email headers
  • subject - Email subject line
  • date - When email was sent
  • message_id - Unique message identifier
  • in_reply_to, references - Threading information
  • reply_to - Reply-to address

Content

  • text - Plain text email body
  • html - HTML email body
  • size_bytes - Total email size

Attachments

  • filename - Original filename
  • content_type - MIME type
  • size_bytes - Attachment size
  • content - Base64-encoded attachment data
  • content_id - Content-ID for inline attachments

Spam Analysis

  • score - Spam score (0-10, higher = more likely spam)
  • verdict - spam or not_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

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

  1. Check URL accessibility - Ensure your endpoint is publicly reachable
  2. Verify HTTPS - Webhook URLs must use SSL/TLS
  3. Check firewall rules - Allow inbound connections on your port
  4. Test with curl - Verify your endpoint responds to POST requests

Signature Verification Fails

  1. Check webhook secret - Ensure you’re using the correct secret
  2. Verify payload - Use exact payload received (including whitespace)
  3. Check algorithm - Must be HMAC-SHA256
  4. Debug signature - Log expected vs actual signatures

High Retry Rates

  1. Improve response times - Respond within 30 seconds
  2. Return proper status codes - Use 2xx for success, avoid 5xx
  3. Handle load - Scale your webhook infrastructure
  4. 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.

Last updated: September 5, 2025