Skip to main content

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 TypeDescription
EMAIL_EVENTDelivery, opens, clicks, bounces, spam reports, and other email activity events
DOMAIN_VERIFICATIONNotifications 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 keyEmitted event valueWhen triggered
deliveryMAIL_DELIVEREDRecipient's mail server accepts the email
openMAIL_OPENEDTracking pixel is loaded (requires open tracking)
clickMAIL_CLICKEDTracked link is clicked (requires click tracking)
bounceMAIL_BOUNCEDelivery failed permanently (hard) or temporarily (soft)
spamMAIL_SPAMRecipient reports spam or spam filter triggers
unsubscribeMAIL_UNSUBSCRIBEDUser clicks unsubscribe link
policy_rejectionMAIL_POLICY_REJECTIONContent or sender violates policies
generation_failureMAIL_GENERATION_FAILURETemplate error or rendering failure
generation_rejectionMAIL_GENERATION_REJECTIONContent validation failed
smtp_errorSMTP_ERRORProvider/SMTP send error (see the failure payload below)
phishingPHISHINGContent flagged as potential phishing
imap_errorIMAP_ERRORError 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

FieldTypeRequiredDescription
webhook_urlstring✅ YesThe HTTPS URL to receive webhook requests. Must be publicly accessible.
eventstring✅ YesEvent type: "EMAIL_EVENT" or "DOMAIN_VERIFICATION"
methodstring✅ YesHTTP method: "GET", "POST", "PUT", "DELETE", "PATCH"
headersobjectNoCustom headers to include in webhook requests (e.g., authentication)
webhook_optionsobjectNoConfiguration for event filtering (only for EMAIL_EVENT)

Webhook Options (for EMAIL_EVENT)

FieldTypeDefaultDescription
events.deliverybooleanfalseReceive delivery notifications
events.openbooleanfalseReceive open tracking events
events.clickbooleanfalseReceive click tracking events
events.bouncebooleanfalseReceive bounce notifications
events.spambooleanfalseReceive spam report notifications
events.unsubscribebooleanfalseReceive unsubscribe notifications
events.policy_rejectionbooleanfalseReceive policy rejection notifications
events.generation_failurebooleanfalseReceive generation failure notifications
events.generation_rejectionbooleanfalseReceive generation rejection notifications
events.smtp_errorbooleanfalseReceive SMTP error notifications
events.phishingbooleanfalseReceive phishing detection notifications
events.imap_errorbooleanfalseReceive 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

ErrorCauseSolution
at least one of webhook_options.events must be trueNo events enabled for EMAIL_EVENTEnable at least one event type
invalid webhook_options.events for DOMAIN_VERIFICATIONEvents specified for domain webhookRemove webhook_options for DOMAIN_VERIFICATION
Webhook Already ExistsWebhook for this event type existsUpdate 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

FieldTypeRequiredDescription
eventstring✅ YesThe event type of the webhook to update
webhook_urlstringNoNew webhook URL (if changing)
methodstringNoNew HTTP method (if changing)
headersobjectNoNew headers (replaces existing)
webhook_optionsobjectNoNew 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

FieldTypeRequiredDescription
eventstring✅ YesThe 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

FieldTypeDescription
eventstringThe emitted event value (e.g. MAIL_DELIVERED, SMTP_ERROR). See the Email Events table.
successbooleantrue for successful events (delivered/open/click), false for failures (bounce/smtp_error).
detailsobjectThe event detail object (below).

details fields

FieldTypeDescription
trackingobjectEngagement/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_idstringThe email's reference id (ULID). Use this for idempotency and to correlate with Email Logs.
mailer_idstringThe sending mailer/domain identifier.
emailstring[]Recipient address(es) for this event.
custom_argsobjectThe custom_args you set when sending the email.
message_idstringProvider message id (when available).
thread_idstringProvider thread id (when available).
webhook_idstringThe id of the webhook subscription that delivered this event.
failed_reasonstringFailure detail. Present (non-empty) on failure events such as SMTP_ERROR / MAIL_BOUNCE.
error_codestringTyped error code on failures (e.g. quota_exhausted, auth_failed). Omitted when not applicable.
num_retriesstringNumber 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

IssueCauseSolution
Webhooks not receivedURL not accessibleEnsure endpoint is publicly accessible
422 Unprocessable EntityCannot connect to URLVerify URL is correct and server is running
Missing eventsEvents not enabledCheck webhook_options.events configuration
Duplicate eventsRetry logicImplement idempotency using ref_id
Authentication failedMissing/wrong headersVerify header configuration