Webhooks
Webhooks deliver real-time notifications to your server when email events occur — delivery, bounces, complaints, opens, and clicks.
Setup
- Go to Dashboard → Webhooks
- Click Register Webhook
- Enter your HTTPS endpoint URL
- Select the event types you want to receive
- Click Register
Or use the Webhooks API.
Event types
| Event | Trigger |
|---|---|
delivered | Email successfully delivered to recipient’s mail server |
soft_bounced | Temporary delivery failure (e.g. mailbox full) |
hard_bounced | Permanent delivery failure (e.g. address doesn’t exist) |
complained | Recipient marked the email as spam |
opened | Recipient opened the email (requires open tracking) |
clicked | Recipient clicked a tracked link |
unsubscribed | Recipient clicked the unsubscribe link |
deferred | Delivery delayed, will retry |
Payload format
{ "event_type": "delivered", "timestamp": "2026-04-20T14:30:00Z", "data": { "message_id": "550e8400-e29b-41d4-a716-446655440000", "to_email": "user@example.com", "subject": "Your order is confirmed" }}See Webhook Events Reference for the full payload for each event type.
Signature verification
Every webhook request includes an X-Emitlo-Signature header. Always verify it before processing the event.
X-Emitlo-Signature: sha256=abc123def456...X-Emitlo-Event: deliveredX-Emitlo-Delivery-Id: 7f3a9b2c-...The signature is HMAC-SHA256(raw_request_body, webhook_secret) encoded as hex.
import crypto from 'crypto';import express from 'express';
const app = express();
app.post('/webhooks/emitlo', express.raw({ type: 'application/json' }), (req, res) => { const signature = req.headers['x-emitlo-signature']; const expected = 'sha256=' + crypto .createHmac('sha256', process.env.WEBHOOK_SECRET) .update(req.body) .digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) { return res.status(401).send('Invalid signature'); }
const event = JSON.parse(req.body); console.log('Event:', event.event_type, event.data.message_id);
res.status(200).send('OK');});<?php$payload = file_get_contents('php://input');$signature = $_SERVER['HTTP_X_EMITLO_SIGNATURE'] ?? '';$expected = 'sha256=' . hash_hmac('sha256', $payload, $_ENV['WEBHOOK_SECRET']);
if (!hash_equals($expected, $signature)) { http_response_code(401); exit('Invalid signature');}
$event = json_decode($payload, true);error_log("Event: {$event['event_type']} — {$event['data']['message_id']}");
http_response_code(200);echo 'OK';import hmac, hashlib, osfrom flask import Flask, request, abort
app = Flask(__name__)
@app.route('/webhooks/emitlo', methods=['POST'])def webhook(): signature = request.headers.get('X-Emitlo-Signature', '') expected = 'sha256=' + hmac.new( os.environ['WEBHOOK_SECRET'].encode(), request.data, hashlib.sha256, ).hexdigest()
if not hmac.compare_digest(signature, expected): abort(401)
event = request.json print(f"Event: {event['event_type']} — {event['data']['message_id']}") return 'OK', 200Retry behavior
If your endpoint returns a non-2xx response or times out, Emitlo retries with exponential backoff:
| Attempt | Delay |
|---|---|
| 1st retry | 1 minute |
| 2nd retry | 5 minutes |
| 3rd retry | 30 minutes |
| 4th retry | 2 hours |
| 5th retry | 8 hours |
After 5 failed attempts, the delivery is marked as failed. You can view delivery logs in the dashboard.
Best practices
- Respond quickly — return
200 OKimmediately, then process the event asynchronously (queue it) - Be idempotent — the same event may be delivered more than once; use
X-Emitlo-Delivery-Idto deduplicate - Handle all event types — even if you only care about
delivered, handle unknown types gracefully - Use HTTPS — webhook endpoints must use HTTPS