Webhooks Guide
Webhooks enable real-time notifications about email events, allowing your application to react immediately to deliveries, opens, clicks, bounces, and other email activities.
Overview
When email events occur (delivery, open, click, bounce, etc.), EDITH sends an HTTP request to your configured webhook endpoint with event details. This enables you to:
- Update email status in your database
- Trigger follow-up actions
- Build real-time analytics dashboards
- Handle bounces and unsubscribes automatically
- Process incoming emails
Webhook Event Types
EDITH supports two main webhook categories:
| Event Type | Description |
|---|---|
EMAIL_EVENT | Delivery, opens, clicks, bounces, spam reports, and other email activity events |
DOMAIN_VERIFICATION | Notifications when domain verification status changes |
Email Events
The keys in the left column are the subscription toggles you set under webhook_options.events.* when registering a webhook. When an event fires, the delivered payload's mailer_events.event field carries the corresponding event value in the right column (note these are uppercase and differ from the subscription key).
| Subscription key | Emitted event value | When triggered |
|---|---|---|
delivery | MAIL_DELIVERED | Recipient's mail server accepts the email |
open | MAIL_OPENED | Tracking pixel is loaded (requires open tracking) |
click | MAIL_CLICKED | Tracked link is clicked (requires click tracking) |
bounce | MAIL_BOUNCE | Delivery failed permanently (hard) or temporarily (soft) |
spam | MAIL_SPAM | Recipient reports spam or spam filter triggers |
unsubscribe | MAIL_UNSUBSCRIBED | User clicks unsubscribe link |
policy_rejection | MAIL_POLICY_REJECTION | Content or sender violates policies |
generation_failure | MAIL_GENERATION_FAILURE | Template error or rendering failure |
generation_rejection | MAIL_GENERATION_REJECTION | Content validation failed |
smtp_error | SMTP_ERROR | Provider/SMTP send error (see the failure payload below) |
phishing | PHISHING | Content flagged as potential phishing |
imap_error | IMAP_ERROR | Error in IMAP inbound monitoring |
Register a Webhook
Endpoint
POST /v1/webhook/register
Purpose
Creates a new webhook endpoint to receive email event notifications.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
webhook_url | string | ✅ Yes | The HTTPS URL to receive webhook requests. Must be publicly accessible. |
event | string | ✅ Yes | Event type: "EMAIL_EVENT" or "DOMAIN_VERIFICATION" |
method | string | ✅ Yes | HTTP method: "GET", "POST", "PUT", "DELETE", "PATCH" |
headers | object | No | Custom headers to include in webhook requests (e.g., authentication) |
webhook_options | object | No | Configuration for event filtering (only for EMAIL_EVENT) |
Webhook Options (for EMAIL_EVENT)
| Field | Type | Default | Description |
|---|---|---|---|
events.delivery | boolean | false | Receive delivery notifications |
events.open | boolean | false | Receive open tracking events |
events.click | boolean | false | Receive click tracking events |
events.bounce | boolean | false | Receive bounce notifications |
events.spam | boolean | false | Receive spam report notifications |
events.unsubscribe | boolean | false | Receive unsubscribe notifications |
events.policy_rejection | boolean | false | Receive policy rejection notifications |
events.generation_failure | boolean | false | Receive generation failure notifications |
events.generation_rejection | boolean | false | Receive generation rejection notifications |
events.smtp_error | boolean | false | Receive SMTP error notifications |
events.phishing | boolean | false | Receive phishing detection notifications |
events.imap_error | boolean | false | Receive IMAP error notifications |
Important: For EMAIL_EVENT webhooks, at least one event type must be set to true.
Example Request - Email Events Webhook
curl -X POST https://api.edith.example.com/v1/webhook/register \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"webhook_url": "https://api.yourcompany.com/webhooks/email-events",
"event": "EMAIL_EVENT",
"method": "POST",
"headers": {
"X-Webhook-Secret": "your-secret-key-for-verification",
"Authorization": "Bearer your-internal-token"
},
"webhook_options": {
"events": {
"delivery": true,
"open": true,
"click": true,
"bounce": true,
"spam": true,
"unsubscribe": true,
"smtp_error": true
}
}
}'
Example Request - Domain Verification Webhook
curl -X POST https://api.edith.example.com/v1/webhook/register \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"webhook_url": "https://api.yourcompany.com/webhooks/domain-status",
"event": "DOMAIN_VERIFICATION",
"method": "POST",
"headers": {
"X-Webhook-Secret": "your-secret-key"
}
}'
Response
{
"success": true,
"message": "webhook created"
}
Validation Errors
| Error | Cause | Solution |
|---|---|---|
at least one of webhook_options.events must be true | No events enabled for EMAIL_EVENT | Enable at least one event type |
invalid webhook_options.events for DOMAIN_VERIFICATION | Events specified for domain webhook | Remove webhook_options for DOMAIN_VERIFICATION |
Webhook Already Exists | Webhook for this event type exists | Update or delete existing webhook |
Update a Webhook
Endpoint
PUT /v1/webhook/update
Purpose
Modifies an existing webhook configuration. You can update the URL, method, headers, or event filters.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
event | string | ✅ Yes | The event type of the webhook to update |
webhook_url | string | No | New webhook URL (if changing) |
method | string | No | New HTTP method (if changing) |
headers | object | No | New headers (replaces existing) |
webhook_options | object | No | New event filters (replaces existing) |
Example Request
curl -X PUT https://api.edith.example.com/v1/webhook/update \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"event": "EMAIL_EVENT",
"webhook_url": "https://api.yourcompany.com/webhooks/v2/email-events",
"webhook_options": {
"events": {
"delivery": true,
"open": true,
"click": true,
"bounce": true,
"spam": true,
"unsubscribe": true,
"policy_rejection": true,
"generation_failure": true,
"smtp_error": true
}
}
}'
Response
{
"success": true,
"message": "webhook updated"
}
Delete a Webhook
Endpoint
DELETE /v1/webhook/delete
Purpose
Removes a webhook configuration. Events will no longer be sent to this endpoint.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
event | string | ✅ Yes | The event type of the webhook to delete: "EMAIL_EVENT" or "DOMAIN_VERIFICATION" |
Example Request
curl -X DELETE https://api.edith.example.com/v1/webhook/delete \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"event": "EMAIL_EVENT"
}'
Response
{
"success": true,
"message": "webhook deleted"
}
Webhook Payload Structure
When an event occurs, EDITH sends an HTTP request to your webhook URL. Every email-event payload uses the same envelope: a top-level wrapper carrying tenant_id, account_id, and schema_version, with the event itself nested under mailer_events.
{
"tenant_id": 7,
"account_id": 7,
"schema_version": 2,
"mailer_events": {
"event": "MAIL_DELIVERED",
"success": true,
"details": { "...": "see below" }
}
}
mailer_events fields
| Field | Type | Description |
|---|---|---|
event | string | The emitted event value (e.g. MAIL_DELIVERED, SMTP_ERROR). See the Email Events table. |
success | boolean | true for successful events (delivered/open/click), false for failures (bounce/smtp_error). |
details | object | The event detail object (below). |
details fields
| Field | Type | Description |
|---|---|---|
tracking | object | Engagement/transport metadata: time, ip, user_agent, browser_name, browser_version, os, device_type, platform, url, country, city, region, time_zone, bot. Populated for open/click; mostly empty otherwise. |
ref_id | string | The email's reference id (ULID). Use this for idempotency and to correlate with Email Logs. |
mailer_id | string | The sending mailer/domain identifier. |
email | string[] | Recipient address(es) for this event. |
custom_args | object | The custom_args you set when sending the email. |
message_id | string | Provider message id (when available). |
thread_id | string | Provider thread id (when available). |
webhook_id | string | The id of the webhook subscription that delivered this event. |
failed_reason | string | Failure detail. Present (non-empty) on failure events such as SMTP_ERROR / MAIL_BOUNCE. |
error_code | string | Typed error code on failures (e.g. quota_exhausted, auth_failed). Omitted when not applicable. |
num_retries | string | Number of delivery attempts so far. Omitted when not applicable. |
Delivery Event Payload (MAIL_DELIVERED)
{
"tenant_id": 7,
"account_id": 7,
"schema_version": 2,
"mailer_events": {
"event": "MAIL_DELIVERED",
"success": true,
"details": {
"tracking": { "time": "2024-01-15T10:30:00Z", "bot": false },
"ref_id": "01JC3BBW8S9YGX2VNKG5MD7BTA",
"mailer_id": "domain_01JC3BBW8S9YGX2VNKG5MD7BTA",
"email": ["recipient@example.com"],
"custom_args": { "order_id": "12345", "user_id": "usr_67890" },
"message_id": "<a1b2c3@mail.yourcompany.com>",
"thread_id": "",
"webhook_id": "email_events_01JC3...",
"failed_reason": ""
}
}
}
Open Event Payload (MAIL_OPENED)
{
"tenant_id": 7,
"account_id": 7,
"schema_version": 2,
"mailer_events": {
"event": "MAIL_OPENED",
"success": true,
"details": {
"tracking": {
"time": "2024-01-15T11:45:00Z",
"ip": "192.168.1.1",
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"browser_name": "Chrome",
"os": "Windows",
"device_type": "desktop",
"country": "US",
"city": "New York",
"region": "NY",
"bot": false
},
"ref_id": "01JC3BBW8S9YGX2VNKG5MD7BTA",
"mailer_id": "domain_01JC3BBW8S9YGX2VNKG5MD7BTA",
"email": ["recipient@example.com"],
"custom_args": { "order_id": "12345" },
"webhook_id": "email_events_01JC3...",
"failed_reason": ""
}
}
}
Click Event Payload (MAIL_CLICKED)
{
"tenant_id": 7,
"account_id": 7,
"schema_version": 2,
"mailer_events": {
"event": "MAIL_CLICKED",
"success": true,
"details": {
"tracking": {
"time": "2024-01-15T11:47:30Z",
"ip": "192.168.1.1",
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"url": "https://yourcompany.com/orders/12345",
"country": "US",
"city": "New York",
"bot": false
},
"ref_id": "01JC3BBW8S9YGX2VNKG5MD7BTA",
"mailer_id": "domain_01JC3BBW8S9YGX2VNKG5MD7BTA",
"email": ["recipient@example.com"],
"custom_args": { "order_id": "12345" },
"webhook_id": "email_events_01JC3...",
"failed_reason": ""
}
}
}
Bounce Event Payload (MAIL_BOUNCE)
The bounce reason (including the provider's SMTP reply, from which hard vs soft can be inferred) is conveyed in details.failed_reason.
{
"tenant_id": 7,
"account_id": 7,
"schema_version": 2,
"mailer_events": {
"event": "MAIL_BOUNCE",
"success": false,
"details": {
"tracking": { "time": "2024-01-15T10:31:00Z", "bot": false },
"ref_id": "01JC3BBW8S9YGX2VNKG5MD7BTA",
"mailer_id": "domain_01JC3BBW8S9YGX2VNKG5MD7BTA",
"email": ["invalid@example.com"],
"custom_args": {},
"webhook_id": "email_events_01JC3...",
"failed_reason": "550 5.1.1 The email account does not exist"
}
}
}
Spam Event Payload (MAIL_SPAM)
{
"tenant_id": 7,
"account_id": 7,
"schema_version": 2,
"mailer_events": {
"event": "MAIL_SPAM",
"success": false,
"details": {
"tracking": { "time": "2024-01-15T12:00:00Z", "bot": false },
"ref_id": "01JC3BBW8S9YGX2VNKG5MD7BTA",
"mailer_id": "domain_01JC3BBW8S9YGX2VNKG5MD7BTA",
"email": ["recipient@example.com"],
"custom_args": {},
"webhook_id": "email_events_01JC3...",
"failed_reason": ""
}
}
}
Unsubscribe Event Payload (MAIL_UNSUBSCRIBED)
{
"tenant_id": 7,
"account_id": 7,
"schema_version": 2,
"mailer_events": {
"event": "MAIL_UNSUBSCRIBED",
"success": true,
"details": {
"tracking": { "time": "2024-01-15T12:30:00Z", "bot": false },
"ref_id": "01JC3BBW8S9YGX2VNKG5MD7BTA",
"mailer_id": "domain_01JC3BBW8S9YGX2VNKG5MD7BTA",
"email": ["recipient@example.com"],
"custom_args": {},
"webhook_id": "email_events_01JC3...",
"failed_reason": ""
}
}
}
Send Failure Payload (SMTP_ERROR)
Emitted when a send fails at the provider/SMTP layer. success is false, failed_reason carries the raw provider error, error_code carries the typed classification, and num_retries reflects attempts made.
{
"tenant_id": 7,
"account_id": 7,
"schema_version": 2,
"mailer_events": {
"event": "SMTP_ERROR",
"success": false,
"details": {
"tracking": { "time": "2024-01-15T13:05:00Z", "bot": false },
"ref_id": "01JC3BBW8S9YGX2VNKG5MD7BTA",
"mailer_id": "oauth_smtp_imap_01JC3BBW8S9YGX2VNKG5MD7BTA",
"email": ["recipient@example.com"],
"custom_args": { "order_id": "12345" },
"failed_reason": "failed to send email: googleapi: Error 403: Daily sending quota exceeded., quotaExceeded",
"error_code": "quota_exhausted",
"num_retries": "3",
"webhook_id": "email_events_01JC3..."
}
}
}
Implementing Your Webhook Endpoint
Basic Express.js Example
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());
// Verify webhook signature (recommended)
function verifySignature(req, secret) {
const signature = req.headers['x-webhook-signature'];
const payload = JSON.stringify(req.body);
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return signature === expectedSignature;
}
app.post('/webhooks/email-events', (req, res) => {
// Verify the webhook is from EDITH
const webhookSecret = process.env.WEBHOOK_SECRET;
if (req.headers['x-webhook-secret'] !== webhookSecret) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { event, details } = req.body.mailer_events;
const recipient = details.email?.[0];
switch (event) {
case 'MAIL_DELIVERED':
console.log(`Email delivered to ${recipient}`);
// Update database, trigger notifications, etc.
break;
case 'MAIL_OPENED':
console.log(`Email opened by ${recipient}`);
break;
case 'MAIL_CLICKED':
console.log(`Link clicked: ${details.tracking?.url}`);
break;
case 'MAIL_BOUNCE':
console.log(`Email bounced: ${details.failed_reason}`);
// Remove invalid emails from your list, e.g. markEmailAsInvalid(recipient)
break;
case 'SMTP_ERROR':
console.log(`Send failed (${details.error_code}): ${details.failed_reason}`);
break;
case 'MAIL_SPAM':
console.log(`Spam complaint from ${recipient}`);
// Add to suppression list
break;
case 'MAIL_UNSUBSCRIBED':
console.log(`Unsubscribed: ${recipient}`);
// Update subscription status
break;
default:
console.log(`Unhandled event: ${event}`);
}
// Always respond with 200 to acknowledge receipt
res.status(200).json({ received: true });
});
app.listen(3000);
Python Flask Example
from flask import Flask, request, jsonify
import hmac
import hashlib
app = Flask(__name__)
@app.route('/webhooks/email-events', methods=['POST'])
def handle_webhook():
# Verify webhook secret
webhook_secret = os.environ.get('WEBHOOK_SECRET')
if request.headers.get('X-Webhook-Secret') != webhook_secret:
return jsonify({'error': 'Unauthorized'}), 401
mailer_events = request.json.get('mailer_events', {})
event_type = mailer_events.get('event')
details = mailer_events.get('details', {})
recipient = (details.get('email') or [None])[0]
if event_type == 'MAIL_DELIVERED':
print(f"Delivered to {recipient}")
# Process delivery
elif event_type == 'MAIL_BOUNCE':
print(f"Bounced: {details.get('failed_reason')}")
# Handle bounce
elif event_type == 'SMTP_ERROR':
print(f"Send failed ({details.get('error_code')}): {details.get('failed_reason')}")
# Handle send failure
elif event_type == 'MAIL_SPAM':
print(f"Spam complaint: {recipient}")
# Handle spam complaint
elif event_type == 'MAIL_UNSUBSCRIBED':
print(f"Unsubscribed: {recipient}")
# Handle unsubscribe
return jsonify({'received': True}), 200
if __name__ == '__main__':
app.run(port=3000)
Best Practices
1. Respond Quickly
Return a 200 OK response as quickly as possible. Process events asynchronously if heavy processing is needed.
// Good: Acknowledge immediately, process later
app.post('/webhooks/email-events', async (req, res) => {
res.status(200).json({ received: true });
// Process asynchronously
setImmediate(() => {
processEvent(req.body);
});
});
2. Implement Idempotency
Webhooks may be retried. Use the ref_id to deduplicate events.
const processedEvents = new Set();
function handleEvent(payload) {
const refId = payload.mailer_events.details.ref_id;
if (processedEvents.has(refId)) {
return; // Already processed
}
processedEvents.add(refId);
// Process the event
}
3. Verify Webhook Authenticity
Always verify that webhooks are from EDITH using the secret header or signature.
if (req.headers['x-webhook-secret'] !== process.env.WEBHOOK_SECRET) {
return res.status(401).json({ error: 'Unauthorized' });
}
4. Handle Retries Gracefully
If your endpoint returns a non-2xx status, EDITH will retry the webhook. Ensure your handler is idempotent.
5. Log All Events
Keep detailed logs for debugging and auditing.
app.post('/webhooks/email-events', (req, res) => {
const { event, details } = req.body.mailer_events;
console.log(JSON.stringify({
timestamp: new Date().toISOString(),
event,
ref_id: details.ref_id,
recipient: details.email?.[0]
}));
// ... handle event
});
6. Monitor Webhook Health
Track webhook success/failure rates and set up alerts for failures.
7. Use HTTPS
Always use HTTPS endpoints to ensure webhook payloads are encrypted in transit.
Troubleshooting
| Issue | Cause | Solution |
|---|---|---|
| Webhooks not received | URL not accessible | Ensure endpoint is publicly accessible |
| 422 Unprocessable Entity | Cannot connect to URL | Verify URL is correct and server is running |
| Missing events | Events not enabled | Check webhook_options.events configuration |
| Duplicate events | Retry logic | Implement idempotency using ref_id |
| Authentication failed | Missing/wrong headers | Verify header configuration |
Related Endpoints
- Send Email - Set custom_args for webhook context
- Email Logs - Query historical event data
- Request Tracing & Rescue - Trace a request end-to-end when an event doesn't arrive
- Inbound Email - Webhook configuration for incoming emails