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, 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 TypeDescription
EMAIL_EVENTDelivery, opens, clicks, bounces, spam reports, send failures, and other outbound email activity
DOMAIN_VERIFICATIONNotifications 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 keyEmitted event valueWhen triggeredProduced by
deliveryMAIL_DELIVEREDRecipient's mail server accepts the emailDirect send (SMTP/OAuth mailers) and SparkPost/Mailgun delivery callbacks
openMAIL_OPENEDTracking pixel is loaded (requires open tracking)EDITH tracker
clickMAIL_CLICKEDTracked link is clicked (requires click tracking)EDITH tracker
bounceMAIL_BOUNCEDelivery failed permanently (hard) or temporarily (soft)SparkPost / Mailgun callbacks
spamMAIL_SPAMRecipient reports spam or spam filter triggersSparkPost / Mailgun callbacks
unsubscribeMAIL_UNSUBSCRIBEDUser clicks unsubscribe linkEDITH tracker and SparkPost / Mailgun callbacks
policy_rejectionMAIL_POLICY_REJECTIONContent or sender violates provider policiesSparkPost callbacks
generation_failureMAIL_GENERATION_FAILURETemplate error or rendering failure at the providerSparkPost callbacks
generation_rejectionMAIL_GENERATION_REJECTIONContent validation failed at the providerSparkPost callbacks
smtp_errorSMTP_ERRORA send attempt failed — emitted on every attempt, not only the last (see Send Failures, Attempts and Retries)Direct send path
phishingPHISHINGOutbound content flagged by phishing protection; the send is stoppedDirect send path
The imap_error toggle is accepted but has no effect here

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 valueWhen triggered
MAILER_DEACTIVATEDA 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 receives IMAP_ERROR), as a single JSON object.
  • SMTP-only configs (smtp, basic_smtp, oauth_smtp) — delivered on the tenant's EMAIL_EVENT webhook, as a one-element array. It bypasses your events.* 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​

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. "POST" is the only accepted value.
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 send-failure notifications (one per attempt)
events.phishingbooleanfalseReceive phishing detection notifications
events.imap_errorbooleanfalseAccepted, 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​

ErrorCauseSolution
at least one of webhook_options.events must be true for EMAIL_EVENTNo core event enabled for EMAIL_EVENTEnable at least one of the ten core event types
invalid webhook_options.events for DOMAIN_VERIFICATIONEmail events specified for a 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 ("POST" only)
headersobjectNoNew headers (replaces existing)
webhook_optionsobjectNoNew 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​

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

The EMAIL_EVENT body is a JSON array

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​

FieldTypeDescription
eventstringThe emitted event value (e.g. MAIL_DELIVERED, SMTP_ERROR). See the Email Events table.
successbooleantrue 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.
detailsobjectThe event detail object (below).

details fields​

FieldTypePresentDescription
ref_idstringAlwaysThe email's reference id (ULID). Correlates with Email Logs. Not unique per event — see Idempotency.
mailer_idstringAlwaysThe sending mailer/domain identifier.
emailstring[]AlwaysRecipient address(es) for this event.
custom_argsobjectAlwaysThe custom_args you set when sending the email (null if none).
message_idstringAlwaysProvider message id; empty string when the provider returned none.
thread_idstringAlwaysProvider thread id; empty string when not applicable.
webhook_idstringAlwaysThe id of the webhook subscription that delivered this event.
event_atstringAlwaysRFC3339 UTC timestamp of when the event occurred (stamped by the producer, not at delivery time). Use this for ordering, not your receive time.
trackingobjectAlwaysEngagement/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_reasonstringAlwaysFailure 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).
attemptnumberSend failures1-based attempt number for this send. Omitted when not applicable.
max_attemptsnumberSend failuresThe configured retry budget for this send. Omitted when not applicable.
retry_scheduledbooleanSend failurestrue when another attempt will be made, false when this was the final one. Always present (explicitly true or false) on send-failure events.
provider_errorstringSend failuresThe raw, unmapped provider/SMTP error string. Omitted when empty.
error_codestringSparkPost bouncesProvider error code, relayed verbatim from SparkPost bounce callbacks only. Not set on SMTP_ERROR.
num_retriesstringSparkPost bouncesProvider retry count, relayed verbatim from SparkPost bounce callbacks only. Not set on SMTP_ERROR.
Choosing between event_at and tracking.time

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 fieldTypeDescription
mailer_idstringThe mailer/config that was deactivated.
config_typestringThe mailer's transport type (e.g. oauth_imap, smtp).
failed_reasonstringReason code: oauth_revoked (OAuth refresh token revoked/expired) or provider_auth_error (SMTP/provider auth rejected).
provider_errorstringThe 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 the EMAIL_EVENT webhook it arrives as an array entry whose details has no ref_id — distinguish it by event, 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:

FieldMeaning
attemptWhich attempt just failed (1-based).
max_attemptsThe configured attempt budget for this send.
retry_scheduledWhether 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.

attemptmax_attemptsretry_scheduledWhat it means
15trueTransient failure (e.g. 4xx throttle). EDITH will retry.
15falsePermanent failure (e.g. 550 invalid recipient, revoked auth, quota exhausted). This is final — no further attempts.
35trueStill retrying.
55falseBudget 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​

FieldWhat it holds
failed_reasonThe 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_errorThe 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​

IssueCauseSolution
Handler reads undefined for eventBody treated as a single objectThe EMAIL_EVENT body is an array — iterate it
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
Several SMTP_ERROR events for one emailExpected — one per attemptBranch on retry_scheduled (false = final)
Duplicate eventsRetry logicDeduplicate on ref_id + event + event_at (+ attempt)
Authentication failedMissing/wrong headersVerify header configuration
IMAP_ERROR never arrives on the EMAIL_EVENT webhookBy designIt is delivered on the IMAP config's own incoming webhook