Webhooks Guide
Webhooks enable real-time notifications about email events, allowing your application to react immediately to deliveries, opens, clicks, bounces, send failures, and other email activities.
Overview
When email events occur (delivery, open, click, bounce, send failure, 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
- React to send failures while they are still being retried
- Process incoming emails
Webhook Event Types
EDITH supports two tenant-registrable webhook categories:
| Event Type | Description |
|---|---|
EMAIL_EVENT | Delivery, opens, clicks, bounces, spam reports, send failures, and other outbound email activity |
DOMAIN_VERIFICATION | Notifications when domain verification succeeds |
Inbound mail (INCOMING_EMAIL) and IMAP health (IMAP_ERROR) are not registered here — they are attached to an individual IMAP/inbound configuration. See Inbound Email.
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 event field carries the corresponding event value in the middle column (note these are uppercase and differ from the subscription key).
| Subscription key | Emitted event value | When triggered | Produced by |
|---|---|---|---|
delivery | MAIL_DELIVERED | Recipient's mail server accepts the email | Direct send (SMTP/OAuth mailers) and SparkPost/Mailgun delivery callbacks |
open | MAIL_OPENED | Tracking pixel is loaded (requires open tracking) | EDITH tracker |
click | MAIL_CLICKED | Tracked link is clicked (requires click tracking) | EDITH tracker |
bounce | MAIL_BOUNCE | Delivery failed permanently (hard) or temporarily (soft) | SparkPost / Mailgun callbacks |
spam | MAIL_SPAM | Recipient reports spam or spam filter triggers | SparkPost / Mailgun callbacks |
unsubscribe | MAIL_UNSUBSCRIBED | User clicks unsubscribe link | EDITH tracker and SparkPost / Mailgun callbacks |
policy_rejection | MAIL_POLICY_REJECTION | Content or sender violates provider policies | SparkPost callbacks |
generation_failure | MAIL_GENERATION_FAILURE | Template error or rendering failure at the provider | SparkPost callbacks |
generation_rejection | MAIL_GENERATION_REJECTION | Content validation failed at the provider | SparkPost callbacks |
smtp_error | SMTP_ERROR | A send attempt failed — emitted on every attempt, not only the last (see Send Failures, Attempts and Retries) | Direct send path |
phishing | PHISHING | Outbound content flagged by phishing protection; the send is stopped | Direct send path |
webhook_options.events.imap_error is still accepted by the register/update endpoints for backward compatibility, but it is never consulted for EMAIL_EVENT delivery. IMAP_ERROR is delivered to the IMAP configuration's own incoming webhook, not to the tenant's EMAIL_EVENT webhook. See Inbound Email.
Mailer Deactivation Events
Beyond per-message events, EDITH emits a MAILER_DEACTIVATED event when a mailer (email configuration) is automatically disabled because its credentials stopped working — the mailer can no longer send or fetch mail until it is re-authenticated.
Emitted event value | When triggered |
|---|---|
MAILER_DEACTIVATED | A mailer is auto-deactivated after an unrecoverable auth failure — OAuth refresh token revoked/expired, or SMTP/provider authentication rejected |
This is not a subscription toggle — there is no webhook_options.events flag for it. It is delivered automatically on webhooks you already have, routed by the mailer's transport:
- IMAP-capable configs (
imap,basic_imap,oauth_imap,smtp_imap) — delivered to that config's own incoming webhook (the same webhook that receivesIMAP_ERROR), as a single JSON object. - SMTP-only configs (
smtp,basic_smtp,oauth_smtp) — delivered on the tenant'sEMAIL_EVENTwebhook, as a one-element array. It bypasses yourevents.*filters entirely.
Only automatic deactivations emit this event — manually disabling a mailer, or a successful re-authentication, is silent. See the Mailer Deactivated Payload below.
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. "POST" is the only accepted value. |
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 send-failure notifications (one per attempt) |
events.phishing | boolean | false | Receive phishing detection notifications |
events.imap_error | boolean | false | Accepted, but not used for EMAIL_EVENT delivery (see note above) |
Important: For EMAIL_EVENT webhooks, at least one event type must be set to true. The check covers the ten core events only — phishing and imap_error do not satisfy it on their own, so pair phishing: true with at least one of the others.
Example Request - Email Events Webhook
curl -X POST https://api.sparrowmailer.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.sparrowmailer.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 for EMAIL_EVENT | No core event enabled for EMAIL_EVENT | Enable at least one of the ten core event types |
invalid webhook_options.events for DOMAIN_VERIFICATION | Email events specified for a 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 ("POST" only) |
headers | object | No | New headers (replaces existing) |
webhook_options | object | No | New event filters (replaces existing) |
Example Request
curl -X PUT https://api.sparrowmailer.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"
}
List Webhooks
Endpoint
GET /v1/webhook
Purpose
Returns the tenant-event webhooks (EMAIL_EVENT, DOMAIN_VERIFICATION) registered for your account. IMAP/inbound webhooks are not included — read those from the IMAP or OAuth config endpoints.
Example Request
curl -X GET https://api.sparrowmailer.com/v1/webhook \
-H "Authorization: Bearer YOUR_TOKEN"
Response
Header values are never returned. headers_present lists the header names you configured, so you can confirm a secret header is set without exposing it.
{
"success": true,
"webhooks": [
{
"webhook_id": "email_events_01JC3BBW8S9YGX2VNKG5MD7BTA",
"event": "EMAIL_EVENT",
"url": "https://api.yourcompany.com/webhooks/email-events",
"method": "POST",
"active": true,
"options": {
"events": {
"delivery": true,
"bounce": true,
"smtp_error": true
}
},
"headers_present": ["X-Webhook-Secret"],
"created_at": "2024-01-10T09:00:00Z",
"updated_at": "2024-01-14T16:20:00Z"
}
]
}
active is worth checking when events stop arriving: a deactivated webhook still updates email status, it just stops delivering.
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.sparrowmailer.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
Every EMAIL_EVENT request body is an array of event objects — never a single object, even when it contains one event. EDITH batches pending events per tenant/account and delivers them in one POST, so a single request can carry several events, for different emails and different event types.
Your handler must iterate the array. Reading req.body.event or req.body.mailer_events will not work.
[
{
"event": "MAIL_DELIVERED",
"success": true,
"details": { "...": "see below" }
},
{
"event": "MAIL_OPENED",
"success": true,
"details": { "...": "see below" }
}
]
There is no tenant_id, account_id, or schema_version wrapper on the wire. Those are internal routing fields; the webhook subscription you registered already identifies the tenant and account, and details.webhook_id echoes the subscription on every event.
Ordering — when every event in a batch carries event_at, the batch is sorted oldest → newest before delivery.
Tracing — deliveries carry a traceparent header so a webhook can be correlated end-to-end with the send that produced it. See Request Tracing & Rescue.
Event object fields
| Field | Type | Description |
|---|---|---|
event | string | The emitted event value (e.g. MAIL_DELIVERED, SMTP_ERROR). See the Email Events table. |
success | boolean | true only for MAIL_DELIVERED, MAIL_OPENED, MAIL_CLICKED and MAIL_UNSUBSCRIBED. Every other event — bounces, spam, send failures, policy/generation rejections, phishing, deactivation — is false. |
details | object | The event detail object (below). |
details fields
| Field | Type | Present | Description |
|---|---|---|---|
ref_id | string | Always | The email's reference id (ULID). Correlates with Email Logs. Not unique per event — see Idempotency. |
mailer_id | string | Always | The sending mailer/domain identifier. |
email | string[] | Always | Recipient address(es) for this event. |
custom_args | object | Always | The custom_args you set when sending the email (null if none). |
message_id | string | Always | Provider message id; empty string when the provider returned none. |
thread_id | string | Always | Provider thread id; empty string when not applicable. |
webhook_id | string | Always | The id of the webhook subscription that delivered this event. |
event_at | string | Always | RFC3339 UTC timestamp of when the event occurred (stamped by the producer, not at delivery time). Use this for ordering, not your receive time. |
tracking | object | Always | 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/unsubscribe; present but zero-valued on other events. |
failed_reason | string | Always | Failure detail. Empty string on success events; on failures it carries the resolved reason (a mapped, human-readable message, or the raw provider error when the code is unmapped). |
attempt | number | Send failures | 1-based attempt number for this send. Omitted when not applicable. |
max_attempts | number | Send failures | The configured retry budget for this send. Omitted when not applicable. |
retry_scheduled | boolean | Send failures | true when another attempt will be made, false when this was the final one. Always present (explicitly true or false) on send-failure events. |
provider_error | string | Send failures | The raw, unmapped provider/SMTP error string. Omitted when empty. |
error_code | string | SparkPost bounces | Provider error code, relayed verbatim from SparkPost bounce callbacks only. Not set on SMTP_ERROR. |
num_retries | string | SparkPost bounces | Provider retry count, relayed verbatim from SparkPost bounce callbacks only. Not set on SMTP_ERROR. |
tracking.time is the engagement timestamp and is only meaningful for open/click/unsubscribe. event_at is set on every event, so use it as the single ordering key across all event types.
Delivery Event Payload (MAIL_DELIVERED)
[
{
"event": "MAIL_DELIVERED",
"success": true,
"details": {
"tracking": { "time": "", "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...",
"event_at": "2024-01-15T10:30:00Z",
"failed_reason": ""
}
}
]
Open Event Payload (MAIL_OPENED)
[
{
"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",
"browser_version": "120.0",
"os": "Windows",
"device_type": "desktop",
"platform": "desktop",
"country": "US",
"city": "New York",
"region": "NY",
"time_zone": "America/New_York",
"bot": false
},
"ref_id": "01JC3BBW8S9YGX2VNKG5MD7BTA",
"mailer_id": "domain_01JC3BBW8S9YGX2VNKG5MD7BTA",
"email": ["recipient@example.com"],
"custom_args": { "order_id": "12345" },
"message_id": "<a1b2c3@mail.yourcompany.com>",
"thread_id": "",
"webhook_id": "email_events_01JC3...",
"event_at": "2024-01-15T11:45:00Z",
"failed_reason": ""
}
}
]
Click Event Payload (MAIL_CLICKED)
[
{
"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",
"browser_name": "Chrome",
"os": "Windows",
"device_type": "desktop",
"country": "US",
"city": "New York",
"bot": false
},
"ref_id": "01JC3BBW8S9YGX2VNKG5MD7BTA",
"mailer_id": "domain_01JC3BBW8S9YGX2VNKG5MD7BTA",
"email": ["recipient@example.com"],
"custom_args": { "order_id": "12345" },
"message_id": "<a1b2c3@mail.yourcompany.com>",
"thread_id": "",
"webhook_id": "email_events_01JC3...",
"event_at": "2024-01-15T11:47:30Z",
"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. Provider bounce callbacks also relay error_code and num_retries.
[
{
"event": "MAIL_BOUNCE",
"success": false,
"details": {
"tracking": { "time": "", "bot": false },
"ref_id": "01JC3BBW8S9YGX2VNKG5MD7BTA",
"mailer_id": "domain_01JC3BBW8S9YGX2VNKG5MD7BTA",
"email": ["invalid@example.com"],
"custom_args": {},
"message_id": "<a1b2c3@mail.yourcompany.com>",
"thread_id": "",
"webhook_id": "email_events_01JC3...",
"event_at": "2024-01-15T10:31:00Z",
"failed_reason": "550 5.1.1 The email account does not exist",
"error_code": "550",
"num_retries": "0"
}
}
]
Spam Event Payload (MAIL_SPAM)
[
{
"event": "MAIL_SPAM",
"success": false,
"details": {
"tracking": { "time": "", "bot": false },
"ref_id": "01JC3BBW8S9YGX2VNKG5MD7BTA",
"mailer_id": "domain_01JC3BBW8S9YGX2VNKG5MD7BTA",
"email": ["recipient@example.com"],
"custom_args": {},
"message_id": "<a1b2c3@mail.yourcompany.com>",
"thread_id": "",
"webhook_id": "email_events_01JC3...",
"event_at": "2024-01-15T12:00:00Z",
"failed_reason": ""
}
}
]
Unsubscribe Event Payload (MAIL_UNSUBSCRIBED)
[
{
"event": "MAIL_UNSUBSCRIBED",
"success": true,
"details": {
"tracking": { "time": "2024-01-15T12:30:00Z", "ip": "192.168.1.1", "bot": false },
"ref_id": "01JC3BBW8S9YGX2VNKG5MD7BTA",
"mailer_id": "domain_01JC3BBW8S9YGX2VNKG5MD7BTA",
"email": ["recipient@example.com"],
"custom_args": {},
"message_id": "<a1b2c3@mail.yourcompany.com>",
"thread_id": "",
"webhook_id": "email_events_01JC3...",
"event_at": "2024-01-15T12:30:00Z",
"failed_reason": ""
}
}
]
Send Failure Payload (SMTP_ERROR)
Emitted when a send attempt fails. success is false, failed_reason carries the resolved reason, provider_error the raw provider string, and attempt / max_attempts / retry_scheduled describe where this attempt sits in the retry budget.
[
{
"event": "SMTP_ERROR",
"success": false,
"details": {
"tracking": { "time": "", "bot": false },
"ref_id": "01JC3BBW8S9YGX2VNKG5MD7BTA",
"mailer_id": "oauth_smtp_imap_01JC3BBW8S9YGX2VNKG5MD7BTA",
"email": ["recipient@example.com"],
"custom_args": { "order_id": "12345" },
"message_id": "",
"thread_id": "",
"webhook_id": "email_events_01JC3...",
"event_at": "2024-01-15T13:05:00Z",
"failed_reason": "Daily sending quota exceeded",
"provider_error": "failed to send email: googleapi: Error 403: Daily sending quota exceeded., quotaExceeded",
"attempt": 2,
"max_attempts": 5,
"retry_scheduled": true
}
}
]
Phishing Payload (PHISHING)
Emitted when outbound phishing protection blocks a message. The send is stopped; there is no retry, and no attempt metadata.
[
{
"event": "PHISHING",
"success": false,
"details": {
"tracking": { "time": "", "bot": false },
"ref_id": "01JC3BBW8S9YGX2VNKG5MD7BTA",
"mailer_id": "domain_01JC3BBW8S9YGX2VNKG5MD7BTA",
"email": ["recipient@example.com"],
"custom_args": {},
"message_id": "",
"thread_id": "",
"webhook_id": "email_events_01JC3...",
"event_at": "2024-01-15T13:10:00Z",
"failed_reason": "content matches credential-harvesting pattern"
}
}
]
Mailer Deactivated Payload (MAILER_DEACTIVATED)
Emitted when a mailer is automatically deactivated (see Mailer Deactivation Events). This payload is purpose-built — unlike per-message events it does not carry tracking, ref_id, email, custom_args, event_at or webhook_id. success is always false.
details field | Type | Description |
|---|---|---|
mailer_id | string | The mailer/config that was deactivated. |
config_type | string | The mailer's transport type (e.g. oauth_imap, smtp). |
failed_reason | string | Reason code: oauth_revoked (OAuth refresh token revoked/expired) or provider_auth_error (SMTP/provider auth rejected). |
provider_error | string | The raw provider/OAuth error string. Omitted when empty. |
IMAP-capable config — delivered to the config's own incoming webhook as a single object:
{
"event": "MAILER_DEACTIVATED",
"success": false,
"details": {
"mailer_id": "oauth_imap_01JC3BBW8S9YGX2VNKG5MD7BTA",
"config_type": "oauth_imap",
"failed_reason": "oauth_revoked",
"provider_error": "oauth2: token expired and refresh token is not set"
}
}
SMTP-only config — delivered on the EMAIL_EVENT webhook as a one-element array, matching that channel's array wire format:
[
{
"event": "MAILER_DEACTIVATED",
"success": false,
"details": {
"mailer_id": "smtp_01JC3BBW8S9YGX2VNKG5MD7BTA",
"config_type": "smtp",
"failed_reason": "provider_auth_error",
"provider_error": "535 5.7.8 Username and Password not accepted"
}
}
]
Integration note: because this event signals a mailer that can no longer send, branch on
event === "MAILER_DEACTIVATED"to alert your team and prompt re-authentication of the affected mailer (config_type+mailer_id). On theEMAIL_EVENTwebhook it arrives as an array entry whosedetailshas noref_id— distinguish it byevent, not by shape.
Domain Verification Payload (DOMAIN_VERIFICATION)
Delivered on the DOMAIN_VERIFICATION webhook when a sending domain finishes verifying. It is a single object (not an array) and carries no success field.
{
"event": "DOMAIN_VERIFICATION",
"details": {
"domain": "mail.yourcompany.com",
"verified": true,
"mailer_id": "domain_01JC3BBW8S9YGX2VNKG5MD7BTA",
"custom_args": {},
"webhook_id": "domain_01JC3..."
}
}
Send Failures, Attempts and Retries
A failed send does not produce one webhook at the end — it produces one SMTP_ERROR event per attempt, as each attempt fails. A send with a budget of 5 attempts that never succeeds delivers 5 SMTP_ERROR events, all sharing the same ref_id.
Three fields tell you where you are:
| Field | Meaning |
|---|---|
attempt | Which attempt just failed (1-based). |
max_attempts | The configured attempt budget for this send. |
retry_scheduled | Whether EDITH will try again. |
retry_scheduled is not derived from the counters. It is true only when the error is classified retryable and budget remains — so a permanent failure reports false on attempt 1 of 5, and you can act on it immediately instead of waiting out the budget.
attempt | max_attempts | retry_scheduled | What it means |
|---|---|---|---|
| 1 | 5 | true | Transient failure (e.g. 4xx throttle). EDITH will retry. |
| 1 | 5 | false | Permanent failure (e.g. 550 invalid recipient, revoked auth, quota exhausted). This is final — no further attempts. |
| 3 | 5 | true | Still retrying. |
| 5 | 5 | false | Budget exhausted. This is final. |
Treat retry_scheduled: false as the terminal event for that send — that is the one to surface to users, write to a failure table, or alert on. Events with retry_scheduled: true are progress signals.
failed_reason vs provider_error
| Field | What it holds |
|---|---|
failed_reason | The resolved reason — a mapped, stable message for known SMTP/OAuth error codes. When the code is unmapped, it falls back to the raw provider error so the real cause is never hidden. Best for display. |
provider_error | The raw provider/SMTP error string, always verbatim. Best for logging, support tickets and debugging. |
Because failed_reason falls back to the raw error for unmapped codes, the two fields are sometimes identical. Do not assume failed_reason is always short or always mapped.
Pre-send failures
Failures that happen before per-recipient processing — template rendering, SMTP-config load, mailer-config load — also emit SMTP_ERROR with the same attempt metadata, for every intended recipient. In those payloads message_id and thread_id are empty, because no message was ever handed to a provider.
Implementing Your Webhook Endpoint
Basic Express.js Example
const express = require('express');
const app = express();
app.use(express.json());
app.post('/webhooks/email-events', (req, res) => {
// Verify the webhook is from EDITH using the header you configured at registration
if (req.headers['x-webhook-secret'] !== process.env.WEBHOOK_SECRET) {
return res.status(401).json({ error: 'Unauthorized' });
}
// The body is always an array — iterate it
const events = Array.isArray(req.body) ? req.body : [req.body];
for (const { event, details } of 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':
if (details.retry_scheduled) {
console.log(
`Attempt ${details.attempt}/${details.max_attempts} failed, retrying: ${details.failed_reason}`
);
} else {
// Terminal: no further attempts will be made
console.error(`Send failed permanently: ${details.failed_reason}`);
console.error(`Provider said: ${details.provider_error}`);
}
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;
case 'MAILER_DEACTIVATED':
console.error(
`Mailer ${details.mailer_id} (${details.config_type}) deactivated: ${details.failed_reason}`
);
// Alert your team and prompt re-authentication
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
import os
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route('/webhooks/email-events', methods=['POST'])
def handle_webhook():
# Verify the header you configured at registration
if request.headers.get('X-Webhook-Secret') != os.environ.get('WEBHOOK_SECRET'):
return jsonify({'error': 'Unauthorized'}), 401
payload = request.get_json(silent=True) or []
events = payload if isinstance(payload, list) else [payload]
for item in events:
event_type = item.get('event')
details = item.get('details', {})
recipient = (details.get('email') or [None])[0]
if event_type == 'MAIL_DELIVERED':
print(f"Delivered to {recipient}")
elif event_type == 'MAIL_BOUNCE':
print(f"Bounced: {details.get('failed_reason')}")
elif event_type == 'SMTP_ERROR':
if details.get('retry_scheduled'):
print(
f"Attempt {details.get('attempt')}/{details.get('max_attempts')} "
f"failed, retrying: {details.get('failed_reason')}"
)
else:
# Terminal: no further attempts
print(f"Send failed permanently: {details.get('failed_reason')}")
print(f"Provider said: {details.get('provider_error')}")
elif event_type == 'MAIL_SPAM':
print(f"Spam complaint: {recipient}")
elif event_type == 'MAIL_UNSUBSCRIBED':
print(f"Unsubscribed: {recipient}")
elif event_type == 'MAILER_DEACTIVATED':
print(
f"Mailer {details.get('mailer_id')} ({details.get('config_type')}) "
f"deactivated: {details.get('failed_reason')}"
)
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(() => {
for (const evt of req.body) processEvent(evt);
});
});
2. Implement Idempotency
Webhooks may be retried, so your handler must be safe to run twice.
ref_id alone is not a deduplication key. One email produces many events (delivered, opened, clicked…), and a failing send produces one SMTP_ERROR per attempt — all with the same ref_id. Deduplicating on ref_id would drop real events.
Use ref_id + event + event_at, adding attempt for send failures:
const processedEvents = new Set();
function handleEvent({ event, details }) {
const key = [
details.ref_id,
event,
details.event_at,
details.attempt ?? ''
].join('|');
if (processedEvents.has(key)) {
return; // Already processed
}
processedEvents.add(key);
// Process the event
}
3. Verify Webhook Authenticity
EDITH does not sign webhook payloads. Authenticity is established by the custom headers you supply at registration — EDITH sends them verbatim on every delivery. Register a secret header and check it on every request:
if (req.headers['x-webhook-secret'] !== process.env.WEBHOOK_SECRET) {
return res.status(401).json({ error: 'Unauthorized' });
}
Treat that header value as a credential: keep it out of source control, and rotate it with PUT /v1/webhook/update.
4. Handle Retries Gracefully
If your endpoint returns a non-2xx status, EDITH will retry the delivery. Ensure your handler is idempotent (see above). Deliveries and their outcomes are queryable — see Get Webhook Delivery Logs for an Email.
5. Log All Events
Keep detailed logs for debugging and auditing.
app.post('/webhooks/email-events', (req, res) => {
for (const { event, details } of req.body) {
console.log(JSON.stringify({
received_at: new Date().toISOString(),
event_at: details.event_at,
event,
ref_id: details.ref_id,
recipient: details.email?.[0]
}));
}
// ... handle events
});
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 |
|---|---|---|
Handler reads undefined for event | Body treated as a single object | The EMAIL_EVENT body is an array — iterate it |
| 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 |
Several SMTP_ERROR events for one email | Expected — one per attempt | Branch on retry_scheduled (false = final) |
| Duplicate events | Retry logic | Deduplicate on ref_id + event + event_at (+ attempt) |
| Authentication failed | Missing/wrong headers | Verify header configuration |
IMAP_ERROR never arrives on the EMAIL_EVENT webhook | By design | It is delivered on the IMAP config's own incoming webhook |
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