Skip to main content

Webhooks API

The Webhooks API configures event notifications from EmailEngine to your application. Instead of polling for changes, EmailEngine posts a JSON payload to your endpoint when an event occurs.

Overview

The webhook system provides:

  • Event filtering: an allowlist of event types to deliver
  • Automatic retries: a failed delivery is retried with exponential backoff
  • Signed payloads: an HMAC signature header on every request
  • Multiple routes: additional targets with their own filter and transform functions

Webhooks vs Polling

AspectWebhooksPolling
LatencyAs soon as EmailEngine notices the changeDepends on the interval
Load on EmailEngineOne request per eventOne request per poll, whether or not anything changed
ComplexityNeeds a public endpointNeeds a scheduler
Missed eventsRetried automaticallyAnything between two polls has to be reconstructed

Webhook Management

1. Register Webhook

The default webhook target and the delivery options are ordinary settings, written with POST /v1/settings.

Endpoint: POST /v1/settings

Detailed API reference

Settings:

SettingTypeDefaultDescription
webhooksstringnoneTarget URL that receives the POST requests. http or https
webhooksEnabledbooleanfalseTurns webhook delivery on or off for all accounts
webhookEventsarraynoneEvent types to deliver. Use ["*"] for all events
webhooksCustomHeadersarraynoneExtra headers on every request, as { "key": ..., "value": ... } objects
notifyHeadersarraynoneMessage headers to include in messageNew payloads, for example ["List-ID"]. ["*"] includes every header
notifyTextbooleantrueInclude the plain text body in messageNew payloads
notifyTextSizeinteger2097152Maximum bytes of text content to include
notifyWebSafeHtmlbooleanfalseInclude sanitized web-safe HTML in payloads
notifyAttachmentsbooleanfalseInclude attachment data in payloads
notifyAttachmentSizeintegernoneMaximum bytes per attachment to include
notifyCalendarEventsbooleanfalseInclude parsed calendar events in payloads
inboxNewOnlybooleanfalseEmit messageNew only for messages arriving in the Inbox

notifyText and notifyTextSize are seeded on first start; the other values are unset until you write them, which behaves as false or none.

Setting a URL is not enough

Delivery only starts once webhooksEnabled is true and webhookEvents lists the events you want. Setting webhooks alone leaves a correctly configured endpoint that never receives anything.

Example:

curl -X POST https://emailengine.example.com/v1/settings \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"webhooks": "https://your-app.com/webhook",
"webhooksEnabled": true,
"webhookEvents": ["messageNew", "messageSent"]
}'

Response:

The response lists the setting keys that were changed:

{
"updated": ["webhooks", "webhooksEnabled", "webhookEvents"]
}

An account can override the target with its own webhooks value in the account record, and a webhook route can send a subset of events somewhere else. See Webhook routing for how the three combine.

2. List Webhook Routes

Endpoint: GET /v1/webhookRoutes

Detailed API reference

Takes page (zero-indexed) and pageSize query parameters.

curl "https://emailengine.example.com/v1/webhookRoutes" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Response:

{
"total": 1,
"page": 0,
"pages": 1,
"webhooks": [
{
"id": "AAABgS-UcAYAAAABAA",
"name": "Send to Slack",
"description": "Notify Slack on new messages",
"targetUrl": "https://your-app.com/webhook",
"enabled": true,
"created": "2021-02-17T13:43:18.860Z",
"updated": "2021-02-17T13:45:00.000Z",
"tcount": 123,
"webhookErrorFlag": null,
"customHeaders": []
}
]
}

tcount counts how many times the route has been applied. webhookErrorFlag carries the message of the last failed delivery, or null.

3. Get Webhook Route

Endpoint: GET /v1/webhookRoutes/webhookRoute/{webhookRoute}

Detailed API reference

curl "https://emailengine.example.com/v1/webhookRoutes/webhookRoute/AAABgS-UcAYAAAABAA" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Response:

{
"id": "AAABgS-UcAYAAAABAA",
"name": "Send to Slack",
"description": "Notify Slack on new messages",
"targetUrl": "https://your-app.com/webhook",
"enabled": true,
"created": "2021-02-17T13:43:18.860Z",
"updated": "2021-02-17T13:45:00.000Z",
"tcount": 123,
"v": 1,
"webhookErrorFlag": null,
"customHeaders": [],
"content": {
"fn": "return true;",
"map": "payload.ts = Date.now(); return payload;"
}
}

content.fn is the filter function and content.map the mapping function, both as source text; either is null when not set.

Webhook routes are read-only through the API

The API exposes only the two read endpoints above. There are no endpoints to create, update, or delete a webhook route: manage them in the admin interface under Webhook Routes.

This is separate from the default webhook target, which you configure with POST /v1/settings as shown above. Routes fan events out to additional destinations with their own filter and map functions.

Webhook Configuration

Target URL

The webhook endpoint must:

  • Be reachable from the EmailEngine host. By default, link-local addresses are blocked and redirects are not followed; see EENGINE_WEBHOOK_EGRESS_POLICY
  • Respond within the delivery timeout, 30 seconds by default (EENGINE_WEBHOOK_TIMEOUT)
  • Return a 2xx status code for success. Anything else, or a timeout, counts as a failed attempt. A redirect is refused rather than followed, and is not retried (since v2.75.0)

Event Filters

webhookEvents is an allowlist. Leaving it out or setting it to [] delivers nothing at all:

{
"webhooks": "https://your-app.com/webhook",
"webhookEvents": ["messageNew", "messageDeleted", "messageSent", "messageDeliveryError"]
}

Subscribe to every event, including types added in later EmailEngine releases, with the * wildcard:

{
"webhooks": "https://your-app.com/webhook",
"webhookEvents": ["*"]
}

Custom Headers

webhooksCustomHeaders adds headers to every request to the default target. Each entry is a key and value pair:

{
"webhooks": "https://your-app.com/webhook",
"webhooksCustomHeaders": [
{ "key": "X-API-Key", "value": "your-secret-key" },
{ "key": "X-Source", "value": "emailengine" }
]
}

Authentication

Bearer token, as a custom header:

{
"webhooks": "https://your-app.com/webhook",
"webhooksCustomHeaders": [
{ "key": "Authorization", "value": "Bearer YOUR_SECRET_TOKEN" }
]
}

Basic auth, as credentials in the URL. EmailEngine strips them from the URL and sends them as an Authorization: Basic header; they are redacted in logs:

{
"webhooks": "https://user:password@your-app.com/webhook"
}

Neither replaces the signature, which is what proves the payload came from your EmailEngine instance and was not altered.

Webhook Payload

Common Payload Structure

{
"serviceUrl": "https://emailengine.example.com",
"account": "user123",
"date": "2025-01-15T10:30:00.000Z",
"path": "INBOX",
"specialUse": "\\Inbox",
"event": "messageNew",
"data": {}
}
FieldTypeDescription
serviceUrlstringThe configured serviceUrl of the EmailEngine instance that sent the event
accountstringAccount identifier
datestringISO 8601 timestamp of when the event was generated
pathstringFolder the event relates to. Omitted for events that are not about a folder
specialUsestringSpecial-use flag of that folder, such as \Inbox or \Sent. Only present when the folder has one
eventstringEvent type, for example messageNew
dataobjectEvent-specific payload

path and specialUse are only present for folder-scoped events, so an account-level event such as authenticationError carries neither. The event ID travels in the X-EE-Wh-Event-Id header rather than in the body.

Event-Specific Fields

Each event type carries its own data object. See the Webhook Events Reference for every field, and the per-event pages linked from it for examples.

Example, messageNew with the default text settings:

{
"serviceUrl": "https://emailengine.example.com",
"account": "user123",
"date": "2025-01-15T10:30:00.000Z",
"path": "INBOX",
"specialUse": "\\Inbox",
"event": "messageNew",
"data": {
"id": "AAAABAABNc",
"uid": 12345,
"date": "2025-01-15T10:29:58.000Z",
"flags": [],
"unseen": true,
"size": 4096,
"subject": "New Email",
"from": {
"name": "John Doe",
"address": "john@example.com"
},
"to": [
{ "address": "user@example.com" }
],
"messageId": "<abc123@example.com>",
"text": {
"id": "AAAADAAABy6TkaMxLjGRozEuMpA",
"encodedSize": {
"plain": 5
},
"plain": "Hello"
}
}
}

Request Headers

EmailEngine sets these headers on every delivery:

HeaderDescription
Content-Typeapplication/json
User-Agentemailengine-app/<version> (+https://emailengine.app/)
X-EE-Wh-IdQueue job ID of this delivery
X-EE-Wh-Attempts-MadeNumber of attempts made before this one, so 0 on the first
X-EE-Wh-Queued-TimeTime since the event was queued, in whole seconds, for example 5s
X-EE-Wh-Event-IdEvent ID, when the event has one. Stable across retries of the same delivery
X-EE-Wh-Custom-RouteID of the webhook route, when the delivery was produced by one
X-EE-Wh-SignatureHMAC-SHA256 of the body, see Webhook Signatures

Event Types

webhookEvents accepts the following names. Payloads are documented in the Webhook Events Reference.

Account Events

EventTrigger
accountAddedAccount registered
accountInitializedThe initial mailbox synchronization finished
accountDeletedAccount deleted
authenticationSuccessThe mail server accepted the credentials
authenticationErrorThe mail server rejected the credentials
connectErrorThe mail server could not be reached

Message Events

EventTrigger
messageNewNew message detected in a folder
messageUpdatedFlags or labels of a message changed
messageDeletedMessage removed from a folder
messageMissingA message that should exist was not found, indicating a synchronization error

Mailbox Events

EventTrigger
mailboxNewFolder created
mailboxDeletedFolder deleted
mailboxResetEmailEngine rebuilt the folder's index, invalidating previously tracked UIDs

Sending Events

EventTrigger
messageSentThe receiving server accepted a queued message
messageDeliveryErrorA delivery attempt failed and will be retried
messageFailedEvery delivery attempt failed
messageBounceA bounce notification arrived for a sent message
messageComplaintA feedback loop complaint (ARF) arrived

Tracking and List Events

EventTrigger
trackOpenA tracked message was opened
trackClickA tracked link was clicked
listSubscribeA recipient subscribed to a mail merge list
listUnsubscribeA recipient unsubscribed from a mail merge list

Export Events

EventTrigger
exportCompletedA bulk message export finished
exportFailedA bulk message export failed

Security

Webhook Signatures

Every delivery carries a signature header:

X-EE-Wh-Signature: <base64url-encoded HMAC-SHA256>

The signature is HMAC-SHA256 over the raw request body, keyed with the serviceSecret setting and base64url encoded. serviceSecret is generated when EmailEngine first starts; read it under Configuration > Security > Service Secret, or set your own value there or through POST /v1/settings.

To verify, compute the same HMAC over the bytes you received and compare in constant time. Parsing the JSON first and re-serializing it will not reproduce the same bytes, so the signature will not match.

const crypto = require('crypto');
const express = require('express');

const app = express();

function verifyWebhook(rawBody, signature, secret) {
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('base64url');

const a = Buffer.from(expected);
const b = Buffer.from(signature || '', 'utf8');

return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// express.raw keeps the exact bytes that were signed
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-ee-wh-signature'];

if (!verifyWebhook(req.body, signature, process.env.WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}

const event = JSON.parse(req.body.toString());
console.log('Event:', event.event);

res.json({ success: true });
});

app.listen(8080);

Source Address Filtering

Restricting the endpoint to the EmailEngine host's address is a useful second layer:

location /webhook {
allow 203.0.113.10;
deny all;
proxy_pass http://127.0.0.1:8080;
}

It is not a substitute for verifying the signature: an allowlist only proves where a request came from, not that its contents are untampered. If EmailEngine sits behind a proxy or NAT, the address your application sees is the proxy's, so verify the signature there too.

HTTPS

Use https targets outside development. The payload carries message content, and a plain http target exposes it, and the custom headers, to anything on the path.

Testing Webhooks

Send a Test Webhook

The admin interface can post a sample event to the configured target:

  1. Open Configuration > Webhooks
  2. Click Send test webhook
  3. Check that your endpoint received it

Testing Tools

Webhook.site gives you a URL that displays everything posted to it, with no code:

https://webhook.site/

ngrok exposes a local receiver to the internet:

ngrok http 8080

Use the generated https:// URL, with /webhook appended, as the target.

Local Testing

A throwaway receiver that prints what arrives:

const express = require('express');
const app = express();

app.use(express.json());

app.post('/webhook', (req, res) => {
console.log(req.body.event, req.body.account);
console.dir(req.body.data, { depth: null });
res.json({ success: true });
});

app.listen(8080, () => console.log('Listening on 8080'));

This one skips signature verification on purpose, which is fine while you are inspecting payloads locally but not something to carry into production. See Webhook Signatures for the verified version.

Post a sample payload to it:

curl -X POST http://localhost:8080/webhook \
-H "Content-Type: application/json" \
-d '{
"event": "messageNew",
"account": "test@example.com",
"data": {
"subject": "Test"
}
}'

Debugging Tips

Check the delivery log:

docker logs emailengine | grep webhook

Check that the endpoint is reachable from the EmailEngine host:

curl -X POST https://your-app.com/webhook \
-H "Content-Type: application/json" \
-d '{"test": true}'

Common causes of failed deliveries:

  • Endpoint not reachable from the EmailEngine host (firewall, DNS, or a destination blocked by the egress policy)
  • Invalid HTTPS certificate
  • No response within the timeout (30 seconds by default)
  • Non-2xx status code
  • Signature verification failing on your side because the body was re-serialized before hashing

Failed deliveries are retried up to 10 times with exponential backoff. The Webhooks queue on the dashboard shows what is waiting and what has failed; see Delivery and Retries.

Building a Receiver

A production webhook endpoint has four jobs, in this order:

  1. Verify the signature over the raw body, before parsing anything.
  2. Deduplicate on the X-EE-Wh-Event-Id header, because a retried delivery repeats an event you may already have handled.
  3. Acknowledge with 2xx as soon as the payload is durably stored.
  4. Do the work afterwards, off the request path, so a slow database never turns into a retry.

Getting the order wrong is what causes the two failure modes seen in practice: parsing before verifying makes the signature unverifiable, and working before acknowledging makes EmailEngine retry a request you are still processing.

const crypto = require('crypto');
const express = require('express');

const app = express();
const SECRET = process.env.WEBHOOK_SECRET; // serviceSecret from EmailEngine settings

// Raw body: the signature covers the exact bytes, not a re-serialized object
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
// 1. Verify before trusting anything in the payload
const expected = crypto.createHmac('sha256', SECRET).update(req.body).digest('base64url');
const given = Buffer.from(req.headers['x-ee-wh-signature'] || '', 'utf8');
const want = Buffer.from(expected);

if (given.length !== want.length || !crypto.timingSafeEqual(want, given)) {
return res.status(401).json({ error: 'Invalid signature' });
}

const eventId = req.headers['x-ee-wh-event-id'];
const event = JSON.parse(req.body.toString());

// 2 + 3. Record it, then acknowledge. A conflict means we already have it
const isNew = await store.insertIfAbsent(eventId, event);
res.json({ success: true });

if (!isNew) return;

// 4. Work happens after the response
queue.push(event).catch(err => console.error('Enqueue failed', eventId, err));
});

app.listen(8080);

Dedupe on the event ID rather than on a composite key you build yourself. X-EE-Wh-Event-Id is stable across retries of the same delivery, whereas a key assembled from event type and message ID collides for legitimately distinct events, such as a message that is flagged twice.

An in-memory set is not deduplication

A Set in the process loses everything on restart and is not shared between instances, so a redeploy or a second replica reprocesses events. Use whatever your application already treats as durable, with a unique constraint on the event ID.

Handle events by type once the payload is on your queue. Only event is guaranteed on every payload, so branch on it and ignore what you do not consume. New event types are added in EmailEngine releases, and a receiver that throws on an unrecognized type starts failing after an upgrade.

See Also