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
- Go to Settings → Webhooks
- Click Create webhook
- Enter your endpoint URL
- Select events to receive
- 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
- Extract timestamp and signature from header
-
Build the signed payload:
{timestamp}.{request_body} - Compute HMAC-SHA256 using your webhook secret
- 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
- Go to Settings → Webhooks → [Your Webhook]
- Click Send test event
- Select event type
- Click Send
Using CLI tools
# Local testing with ngrok
ngrok http 3000
# Then update webhook URL to ngrok URL
Best practices
- Always verify signatures — Prevent spoofed requests
- Respond quickly — Process async, return immediately
- Handle duplicates — Webhooks may retry
- Monitor failures — Set up alerts for failed deliveries
- Use HTTPS — We only send to secure endpoints
- 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
- Create templates for consistent emails
- Set up validation to maintain list hygiene
- Monitor deliverability with analytics