Skip to main content

Webhooks

Webhooks are the primary mechanism for receiving real-time notifications from EmailEngine about mailbox events, message changes, and delivery status. Instead of repeatedly polling for updates, EmailEngine pushes notifications to your application as events occur.

What Webhooks Cover​

EmailEngine posts a JSON document to your endpoint as each event happens, so an integration does not have to poll for changes. The events fall into five groups:

  • Message lifecycle - a message arrived, its flags or labels changed, it is gone
  • Delivery status - a queued message was accepted, an attempt failed, EmailEngine gave up, a bounce or a complaint came back
  • Mailbox changes - a folder appeared, disappeared, or had its UIDVALIDITY reset
  • Account status - registered, initialized, authenticated, failing to connect, deleted
  • Recipient actions - opens, clicks, unsubscribes

Deliveries are queued rather than sent inline with the mail operation that produced them, so a slow endpoint delays webhooks but never syncing.

Setting Up Webhooks​

1. Configure Webhook URL​

Set your webhook endpoint URL in EmailEngine:

Via Web UI:

  1. Navigate to Configuration > Webhooks
  2. Check Enable Webhooks
  3. Enter your Webhook URL: https://your-app.com/webhooks/emailengine
  4. Under Event Types, select which events to receive. Selecting none means no webhooks are sent
  5. Click Save Changes

Webhooks configuration page The Webhooks settings page with the target URL and event selection

Via API:

Use the settings API to configure webhooks:

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/webhooks/emailengine",
"webhooksEnabled": true,
"webhookEvents": ["*"],
"notifyHeaders": ["list-id", "x-priority"],
"notifyTextSize": 65536,
"notifyWebSafeHtml": true,
"notifyCalendarEvents": true
}'
webhookEvents is an allowlist with no default

Nothing is delivered unless the event is named in webhookEvents, and an unset webhookEvents names nothing. ["*"] allows every event; a list of names allows exactly those. This is the first thing to check when a correctly configured URL receives nothing.

The allowlist applies to the default webhook target above. Webhook routes carry their own filters and are not affected by it.

Advanced: Webhook Routes

For more advanced scenarios, you can configure multiple webhook routes to send different events to different endpoints based on account, event type, or custom filtering logic. Webhook routes also support pre-processing functions to filter or transform payloads before delivery.

Routes are created and edited in the admin interface under Webhooks; the API only lists them. See Webhook Routes for the filter and map functions a route can carry.

2. Create Webhook Handler​

Your webhook endpoint must:

  • Accept HTTP POST requests
  • Return a 2xx status code before the delivery times out (30 seconds by default)
  • Process events asynchronously, so that acknowledging never waits on your own work
const express = require('express');
const app = express();

app.use(express.json());

app.post('/webhooks/emailengine', async (req, res) => {
const event = req.body;

// Acknowledge receipt immediately
res.status(200).json({ success: true });

// Process asynchronously
processEvent(event).catch(err => {
console.error('Webhook processing error:', err);
});
});

async function processEvent(event) {
console.log(`Received ${event.event} event for account ${event.account}`);

switch (event.event) {
case 'messageNew':
await handleNewMessage(event);
break;
case 'messageSent':
await handleMessageSent(event);
break;
case 'messageFailed':
await handleMessageFailed(event);
break;
// Handle other events...
}
}

app.listen(3000, () => {
console.log('Webhook server running on port 3000');
});

Delivery and Retries​

Each event becomes a job in the notify queue, and the queue worker posts it.

BehaviorValue
SuccessAny 2xx response
RetriedEvery other outcome: a 4xx or 5xx response, a refused or dropped connection, a TLS failure, a timeout. A 4xx is retried on purpose, so that an endpoint whose path or credentials are being fixed still receives the event once it is
Attempts10, counting the first
BackoffExponential, starting at 5 seconds, with 20% jitter
Per-attempt timeout30 seconds, configurable with EENGINE_WEBHOOK_TIMEOUT
After the last attemptThe event is dropped. The job moves to the Failed tab of the Webhooks Queue, where the last 500 failures are kept for 7 days

Two failures are treated as final and consume the whole retry budget at once, because retrying could not change the outcome: a destination refused by the egress policy (EEGRESSBLOCKED) and an endpoint that answers with a redirect (EREDIRECTNOTFOLLOWED).

Design your handler for repeat deliveries

Because a timed-out or failed attempt is retried, your endpoint will sometimes receive the same event more than once. Respond 2xx as soon as you have durably accepted the payload and do the real work afterwards, and deduplicate on the X-EE-Wh-Event-Id header. This matters most for handlers with side effects, such as creating a ticket or charging for a message.

Webhook Events​

Every event name EmailEngine can send is listed, with its payload, on the Webhook Events Reference. The events fall into six groups, and each has its own page:

GroupEventsSent when
MessagemessageNew, messageDeleted, messageUpdated, messageMissingA message in a synced folder arrives, is removed, changes flags or labels, or cannot be fetched
DeliverymessageSent, messageDeliveryError, messageFailed, messageBounce, messageComplaintA queued message is accepted, an SMTP attempt fails, EmailEngine gives up on a message, or a bounce or feedback-loop report arrives
MailboxmailboxNew, mailboxDeleted, mailboxResetA folder appears, disappears, or has to be re-indexed
AccountaccountAdded, accountInitialized, accountDeleted, authenticationSuccess, authenticationError, connectErrorAn account is registered, finishes its first sync, is removed, or its login or connection succeeds or fails
TrackingtrackOpen, trackClick, listUnsubscribe, listSubscribeA recipient opens a tracked message, clicks a tracked link, or unsubscribes from or re-subscribes to a list
ExportexportCompleted, exportFailedA bulk export finishes or fails

Testing Webhooks​

Using webhook.site​

The easiest way to test webhooks is using a temporary webhook inspector:

  1. Visit https://webhook.site/
  2. Copy your unique webhook URL
  3. Set it as the webhook URL in EmailEngine
  4. Trigger an event (send a test email, add an account, etc.)
  5. View the webhook payload in real-time

Tailing Webhooks to a Log File​

For ongoing webhook monitoring, you can log all webhooks to a file and tail them:

Step 1: Create log file

sudo touch /var/log/emailengine-webhooks.log
sudo chown www-data /var/log/emailengine-webhooks.log # Adjust user as needed

Step 2: Create PHP webhook logger

<?php
// webhook-logger.php

$logFile = '/var/log/emailengine-webhooks.log';

// Read webhook payload
$payload = file_get_contents('php://input');
$headers = getallheaders();

// Create log entry
$logEntry = [
'timestamp' => date('c'),
'method' => $_SERVER['REQUEST_METHOD'],
'headers' => $headers,
'request' => json_decode($payload, true)
];

// Append to log file
file_put_contents($logFile, json_encode($logEntry) . "\n", FILE_APPEND);

// Respond
http_response_code(200);
header('Content-Type: application/json');
echo json_encode(['success' => true]);
?>

Step 3: Tail the log with jq

# Install jq if needed
sudo apt update && sudo apt install -y jq

# Tail and pretty-print webhooks
tail -f /var/log/emailengine-webhooks.log | jq

This gives you a real-time, pretty-printed view of all incoming webhooks.

Send Test Webhook​

EmailEngine allows you to send a test webhook from the UI:

  1. Go to Configuration > Webhooks
  2. Click Send test webhook
  3. Check your webhook endpoint receives the test

Debugging Webhooks​

If webhooks aren't working as expected, follow this diagnostic process:

1. Verify External Connectivity​

Test with webhook.site:

  1. Set webhook URL to https://webhook.site/your-unique-id
  2. Trigger an event in EmailEngine
  3. Check if webhook.site receives the request

If no request appears:

  • Check firewall rules
  • Verify DNS resolution
  • Ensure EmailEngine can make outbound HTTPS requests
  • Check for typos in webhook URL
  • Check whether EmailEngine refused the destination itself, see below

Blocked destinations and redirects​

EmailEngine will not deliver to every address. Two refusals come from EmailEngine rather than from the network, and both report themselves in the webhook error flag on the account or configuration page:

Error codeMeaningFix
EEGRESSBLOCKEDThe destination resolves to an address the egress policy blocks. By default that is the link-local range where cloud instance metadata services livePoint the webhook at a routable address, or widen EENGINE_WEBHOOK_EGRESS_POLICY
EREDIRECTNOTFOLLOWEDThe endpoint answered with a redirect (301, 302, 307, ...). Since v2.75.0 redirects are refused rather than followed, because a permitted host could redirect to a blocked oneConfigure the webhook with the endpoint's final URL

Both fail immediately without consuming the retry budget, since the same address would be refused, and the same endpoint would redirect, on every attempt.

Both checks apply to the Send test webhook button as well, so the button reports the same refusal you would see on a real delivery. Set EENGINE_WEBHOOK_EGRESS_POLICY=off to restore the previous behavior, including following redirects.

See Webhook Delivery settings for the available policies.

2. Monitor Webhook Queue​

EmailEngine uses BullMQ to manage webhook delivery. To inspect webhook jobs:

  1. Go to System > Queues
  2. Open Webhooks
  3. Check these tabs:
    • Active: Currently being posted
    • Delayed: An attempt failed and the next one is waiting out the backoff
    • Failed: Every attempt is spent, or the failure was final
    • Completed: The endpoint answered 2xx, or the event was dropped before delivery because no target was set or the event is not in webhookEvents

Failed webhooks are retained with full error details by default, so there is nothing to enable before inspecting them. To also keep successful deliveries, go to Configuration > General and set Completed job history limit to, for example, 100. See Queue Management.

3. Inspect Failed Jobs​

Open a failed delivery in the queue browser to see:

  • What failed, in plain terms (the endpoint answered 500, the endpoint did not answer in time, the destination is blocked by the egress policy) with the error text under it
  • Which webhook route or default target it was for, and which account and event
  • How many attempts were made and when
  • The payload that was being delivered, the failure reason and the stack trace of each attempt, under Raw queue record

A failed delivery can be retried from the same page, with a fresh set of attempts. The webhook route page links straight to the route's failed and waiting deliveries.

The response body from your endpoint is read and discarded rather than stored, so a failure is described by its status code and error code alone.

Common failures:

  • ETIMEDOUT: The attempt ran past the 30 second cap, either connecting or reading the response
  • Socket errors: The host is unreachable, refuses the connection, or does not resolve
  • TLS errors: The endpoint's certificate did not validate
  • A 4xx status: Your endpoint rejected the request. Check the path and any authentication headers
  • A 5xx status: Your endpoint failed while handling it
  • EEGRESSBLOCKED or EREDIRECTNOTFOLLOWED: EmailEngine refused the destination, see Blocked destinations and redirects

4. Verify Event Generation​

Test if events are being generated at all:

Add a new account:

  • Should trigger accountAdded and accountInitialized events

Send test email to an account:

  • Should trigger messageNew within 10-60 seconds
  • If not, verify message arrived (check via webmail)
  • Check message is visible via API:
curl "https://emailengine.example.com/v1/account/user123/messages?path=INBOX" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"

List messages API

If message is missing:

  • Wrong account credentials
  • OAuth token lacks required scopes
  • Message filtered to different folder

5. Special Requirements for API Backends​

Gmail API + Cloud Pub/Sub​

If using Gmail API (not IMAP):

  1. Go to Integrations > OAuth2 Apps
  2. Select your Gmail OAuth app
  3. Scroll to Cloud Pub/Sub configuration
  4. Verify all show Created (in green):
    • Topic
    • Subscription
    • Gmail publishing bindings

If not created:

  • Google Cloud service account missing IAM roles
  • Pub/Sub API not enabled
  • Invalid credentials

Microsoft Graph API​

If using MS Graph (not IMAP):

  1. Go to Accounts
  2. Select the account
  3. Scroll to Change subscription
  4. Verify status is Created and expiration is in future

If not created:

  • EmailEngine not reachable from Microsoft servers
  • TLS certificate invalid
  • Service URL not configured correctly
  • OAuth app missing required scopes

Microsoft Graph requires these endpoints to be publicly accessible:

https://emailengine.example.com/oauth/msg/lifecycle
https://emailengine.example.com/oauth/msg/notification

Webhook Security​

Use HTTPS​

A webhook payload carries subjects, addresses, and, with notifyText on, message bodies. Use an https: target so that content is not readable in transit, and so that credentials embedded in the URL or carried in a custom header are not exposed.

Verify Webhook Authenticity​

Every webhook is signed with HMAC-SHA256 over the raw request body, sent as X-EE-Wh-Signature. The key is the serviceSecret setting, which EmailEngine generates automatically the first time it needs one, so the header is present whether or not you configured anything. Set your own value so that you know the secret your handler should verify against.

1. Set a service secret using the settings API:

curl -X POST "https://emailengine.example.com/v1/settings" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"serviceSecret": "your-secret-key-here"}'

2. Verify signature in your handler:

The signature is computed on the raw request body using HMAC-SHA256 and encoded as base64url.

const crypto = require('crypto');

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

// Compare in constant time so the comparison cannot be used as an oracle
const a = Buffer.from(expected);
const b = Buffer.from(signature || '', 'utf8');

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

// Important: Use raw body parser to get the exact bytes for signature verification
app.post('/webhooks/emailengine', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-ee-wh-signature'];
const secret = process.env.SERVICE_SECRET;

if (!verifyWebhookSignature(req.body, signature, secret)) {
return res.status(401).json({ error: 'Invalid signature' });
}

// Parse body after verification
const event = JSON.parse(req.body.toString());

// Process webhook...
res.json({ success: true });
});

Advanced Webhook Settings​

Inbox-Only Webhooks (inboxNewOnly)​

By default, EmailEngine triggers messageNew webhooks for new messages in all monitored folders. If you only care about incoming mail, enable the inboxNewOnly setting to limit messageNew webhooks to messages arriving in the Inbox folder only.

curl -X POST "https://emailengine.example.com/v1/settings" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"inboxNewOnly": true}'

When enabled:

  • messageNew webhooks are only triggered for messages in the Inbox
  • Messages arriving in Sent, Drafts, Junk, Trash, and other folders are silently ignored
  • Other webhook events (messageDeleted, messageUpdated, etc.) are not affected

This is useful for reducing webhook volume when you only need to process incoming emails.

Custom Request Headers (webhooksCustomHeaders)​

webhooksCustomHeaders adds headers to every request sent to the default webhook target. It is an array of {key, value} objects:

curl -X POST "https://emailengine.example.com/v1/settings" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"webhooksCustomHeaders": [
{ "key": "Authorization", "value": "Bearer my-endpoint-token" },
{ "key": "X-Tenant", "value": "acme" }
]
}'

In the admin interface the same list is Custom Headers on Configuration > Webhooks, written one Key: Value pair per line.

For the default target, headers come from two lists, applied in this order, so the second overwrites a header of the same name from the first:

  1. webhooksCustomHeaders
  2. The account's own webhooksCustomHeaders, set through the Update Account API

A delivery that belongs to a webhook route carries the route's own header list and nothing else. Since v2.82.0 neither the global nor the account list is added to it, so an account's credentials never reach a route's endpoint and an account header cannot override the route's Authorization. Earlier releases applied the account's headers on top of the route's.

Webhook Error Tracking (webhookErrorFlag)​

EmailEngine automatically tracks webhook delivery errors. When a webhook delivery fails, the error details are stored and displayed in the admin panel: on Configuration > Webhooks for the default target, on the account details page for an account-specific target, and on the route's page for a webhook route. When a subsequent delivery to the same target succeeds, the error flag is automatically cleared.

The error flag includes:

  • Event type that failed
  • Error message
  • Webhook URL
  • Error code and HTTP status code
  • Timestamp

This is an internal tracking mechanism - there is no configuration needed. Check the admin panel or account details API response for the current webhook error status.

Webhook HTTP Headers​

EmailEngine includes the following HTTP headers with each webhook request:

HeaderDescription
X-EE-Wh-Event-IdUnique identifier for the event. Use for deduplication. Present whenever the payload carries an event ID, and moved out of the JSON body into this header.
X-EE-Wh-SignatureHMAC-SHA256 signature of the request body, base64url encoded. Always sent, see Verify Webhook Authenticity.
X-EE-Wh-IdQueue job ID for this delivery. Identifies the job in System > Queues.
X-EE-Wh-Attempts-MadeHow many attempts have already been made for this delivery. 0 on the first attempt.
X-EE-Wh-Queued-TimeHow long the event waited in the queue before this attempt, in seconds (for example 3s).
X-EE-Wh-Custom-RouteID of the custom route that produced this delivery. Only sent for route deliveries.
User-Agentemailengine-app/<version> (+https://emailengine.app/), for example emailengine-app/2.82.0 (+https://emailengine.app/)
Content-TypeAlways application/json
Content-LengthSize of the request body in bytes
AuthorizationBasic credentials, only when the webhook URL itself embeds a user name or password. They are taken out of the URL and moved into this header. Percent-encoded characters in the user name or password are decoded first (v2.79.8; earlier releases sent them encoded)

Custom headers are added on top of this set, see Custom Request Headers.

Using the Event ID for Deduplication:

const processedEvents = new Set();

app.post('/webhooks/emailengine', (req, res) => {
const eventId = req.headers['x-ee-wh-event-id'];

// Skip if already processed (idempotency)
if (processedEvents.has(eventId)) {
return res.json({ success: true, skipped: true });
}

processedEvents.add(eventId);

// Process the event...
res.json({ success: true });
});

See Also​