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:
- Navigate to Configuration > Webhooks
- Check Enable Webhooks
- Enter your Webhook URL:
https://your-app.com/webhooks/emailengine - Under Event Types, select which events to receive. Selecting none means no webhooks are sent
- Click Save Changes
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 defaultNothing 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.
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.
See Webhooks API for route management and Pre-Processing Functions for custom filters.
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
- Node.js
- Python
- PHP
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');
});
from concurrent.futures import ThreadPoolExecutor
from flask import Flask, jsonify, request
app = Flask(__name__)
executor = ThreadPoolExecutor(max_workers=4)
@app.route('/webhooks/emailengine', methods=['POST'])
def webhook_handler():
event = request.get_json()
# Hand the work off to a worker thread and acknowledge right away
executor.submit(process_event, event)
return jsonify({'success': True}), 200
def process_event(event):
event_type = event.get('event')
account = event.get('account')
print(f"Processing {event_type} for {account}")
if event_type == 'messageNew':
handle_new_message(event)
elif event_type == 'messageSent':
handle_message_sent(event)
# Handle other events...
if __name__ == '__main__':
app.run(port=3000)
<?php
// webhook.php
// Read the webhook payload
$payload = file_get_contents('php://input');
$event = json_decode($payload, true);
// Respond immediately
http_response_code(200);
header('Content-Type: application/json');
echo json_encode(['success' => true]);
// Close connection and process asynchronously
fastcgi_finish_request();
// Process the event
processEvent($event);
function processEvent($event) {
$eventType = $event['event'] ?? '';
$account = $event['account'] ?? '';
error_log("Processing $eventType for $account");
switch ($eventType) {
case 'messageNew':
handleNewMessage($event);
break;
case 'messageSent':
handleMessageSent($event);
break;
// Handle other events...
}
}
?>
Delivery and Retries
Each event becomes a job in the notify queue, and the queue worker posts it.
| Behavior | Value |
|---|---|
| Success | Any 2xx response |
| Attempts | 10, counting the first |
| Backoff | Exponential, starting at 5 seconds, with 20% jitter |
| Per-attempt timeout | 30 seconds, configurable with EENGINE_WEBHOOK_TIMEOUT |
| After the last attempt | The 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).
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
EmailEngine sends different types of events organized into categories:
Message Events
Events related to emails in monitored mailbox folders. These webhooks notify you when messages arrive, are modified, or are removed from the mailbox.
messageNew
Triggered when a new message is detected in a mailbox folder. This is one of the most commonly used webhook events, enabling real-time processing of incoming emails.
messageDeleted
Triggered when a previously tracked email has been removed from a mailbox folder. Helps keep external systems synchronized with mailbox state changes.
See full messageDeleted reference
messageUpdated
Triggered when EmailEngine detects that the flags or labels on a message have changed, enabling real-time synchronization of message state changes with external systems.
See full messageUpdated reference
messageMissing
Triggered when EmailEngine detects that a message it expected to find on the mail server is not available. This event indicates a potential synchronization issue and helps handle edge cases in message processing.
See full messageMissing reference
Delivery Events
Events related to outgoing email delivery. These webhooks track the lifecycle of messages sent through EmailEngine, from successful delivery to failures, bounces, and spam complaints.
messageSent
Triggered when a queued message is successfully accepted by the SMTP server or email API (Gmail API, Microsoft Graph API). This event confirms that the message has been handed off to the mail transfer agent for delivery.
See full messageSent reference
messageDeliveryError
Triggered for every failed SMTP delivery attempt, whether or not the message will be retried. Only SMTP submissions produce it.
See full messageDeliveryError reference
messageFailed
Triggered when EmailEngine gives up on a queued email, either because the attempts ran out or because a failure was permanent. Sent for SMTP, Gmail API and Microsoft Graph submissions alike.
See full messageFailed reference
messageBounce
Triggered when a bounce notification (Delivery Status Notification) is received in a monitored mailbox. EmailEngine parses the bounce message to extract delivery failure information including the failed recipient, SMTP error codes, and details about the original message.
See full messageBounce reference
messageComplaint
Triggered when a feedback loop (FBL) complaint is detected. EmailEngine parses ARF (Abuse Reporting Format) complaint messages to extract information about the complainant and the original message that was reported as spam.
See full messageComplaint reference
Mailbox Events
Events related to mailbox folder changes. These webhooks notify you when folders are created, deleted, or reset on the mail server.
mailboxNew
Triggered when a new folder is discovered on the mail server during synchronization.
mailboxDeleted
Triggered when a previously tracked folder is no longer found on the mail server.
See full mailboxDeleted reference
mailboxReset
Triggered when a folder's UIDVALIDITY changes, indicating a mailbox reset. This is a rare but significant event that invalidates all previously tracked message UIDs in the folder.
See full mailboxReset reference
Account Events
Events related to email account lifecycle and connection status. These webhooks track account registration, initialization, authentication, and connection health.
accountAdded
Triggered when a new email account is registered with EmailEngine. This is the first webhook in the account lifecycle, fired before authentication is attempted.
See full accountAdded reference
accountInitialized
Triggered when an email account completes its initial mailbox synchronization. This marks the point at which the account is fully operational and ready for use.
See full accountInitialized reference
accountDeleted
Triggered when an email account is removed from EmailEngine. This is the final webhook event in the account lifecycle.
See full accountDeleted reference
authenticationSuccess
Triggered when EmailEngine successfully authenticates an email account for the first time or after recovering from an error state.
See full authenticationSuccess reference
authenticationError
Triggered when EmailEngine fails to authenticate an email account due to invalid credentials, expired OAuth2 tokens, or API authentication errors.
See full authenticationError reference
connectError
Triggered when EmailEngine fails to establish a connection to an email server due to network issues, server unavailability, or TLS/SSL problems. This is distinct from authentication errors which occur after connection is established.
See full connectError reference
Tracking Events
Events related to email engagement tracking. These webhooks notify you when recipients open emails, click links, or manage their subscription preferences.
trackOpen
Triggered when a recipient opens an email that has open tracking enabled. The tracking works by embedding a 1x1 pixel image in the email's HTML body that is loaded when the email is viewed.
trackClick
Triggered when a recipient clicks a tracked link in an email that has click tracking enabled. EmailEngine rewrites links in outgoing HTML emails to redirect through a tracking endpoint, capturing click events before redirecting recipients to the original destination.
listUnsubscribe
Triggered when a recipient uses the one-click unsubscribe mechanism to remove themselves from a mailing list. EmailEngine adds the recipient to the suppression list and fires this webhook.
See full listUnsubscribe reference
listSubscribe
Triggered when a recipient re-subscribes to a mailing list after previously unsubscribing. This event enables you to restore subscriptions and keep your mailing lists synchronized.
See full listSubscribe reference
Export Events
Events related to bulk email export jobs. These webhooks notify you when export jobs complete or fail.
exportCompleted
Triggered when a bulk email export job finishes successfully. The export file is ready for download.
See full exportCompleted reference
exportFailed
Triggered when a bulk email export job fails. The payload names the phase it failed in and how many messages had been written. An export cannot be resumed; start a new one.
See full exportFailed reference
Testing Webhooks
Using webhook.site
The easiest way to test webhooks is using a temporary webhook inspector:
- Visit https://webhook.site/
- Copy your unique webhook URL
- Set it as the webhook URL in EmailEngine
- Trigger an event (send a test email, add an account, etc.)
- 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:
- Go to Configuration > Webhooks
- Click Send test webhook
- 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:
- Set webhook URL to https://webhook.site/your-unique-id
- Trigger an event in EmailEngine
- 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 code | Meaning | Fix |
|---|---|---|
EEGRESSBLOCKED | The destination resolves to an address the egress policy blocks. By default that is the link-local range where cloud instance metadata services live | Point the webhook at a routable address, or widen EENGINE_WEBHOOK_EGRESS_POLICY |
EREDIRECTNOTFOLLOWED | The 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 one | Configure 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:
- Go to System > Queues
- Select Webhooks Queue
- 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 inwebhookEvents
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 Job History Limit to, for example, 100. See Queue Management.
3. Inspect Failed Jobs
Click on a failed job in Bull Board to see:
- The payload that was being delivered
- The failure reason and the stack trace of each attempt
- How many attempts were made
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
4xxstatus: Your endpoint rejected the request. Check the path and any authentication headers - A
5xxstatus: Your endpoint failed while handling it EEGRESSBLOCKEDorEREDIRECTNOTFOLLOWED: 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
accountAddedandaccountInitializedevents
Send test email to an account:
- Should trigger
messageNewwithin 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"
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):
- Go to Integrations > OAuth2 Apps
- Select your Gmail OAuth app
- Scroll to Cloud Pub/Sub configuration
- Verify all show Created (in green):
- Topic
- Subscription
- Gmail 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):
- Go to Accounts
- Select the account
- Scroll to Change subscription
- 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:
messageNewwebhooks 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.
Headers come from two places, applied in this order, so the second overwrites a header of the same name from the first:
webhooksCustomHeaders, or the route's own header list when the delivery belongs to a webhook route. A route replaces the global list for its own deliveries rather than adding to it- The account's own
webhooksCustomHeaders, set through the Update Account API
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 the account details page. When a subsequent webhook delivery 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:
| Header | Description |
|---|---|
X-EE-Wh-Event-Id | Unique 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-Signature | HMAC-SHA256 signature of the request body, base64url encoded. Always sent, see Verify Webhook Authenticity. |
X-EE-Wh-Id | Queue job ID for this delivery. Identifies the job in System > Queues. |
X-EE-Wh-Attempts-Made | How many attempts have already been made for this delivery. 0 on the first attempt. |
X-EE-Wh-Queued-Time | How long the event waited in the queue before this attempt, in seconds (for example 3s). |
X-EE-Wh-Custom-Route | ID of the custom route that produced this delivery. Only sent for route deliveries. |
User-Agent | emailengine-app/<version> (+https://emailengine.app/), for example emailengine-app/2.79.4 (+https://emailengine.app/) |
Content-Type | Always application/json |
Content-Length | Size of the request body in bytes |
Authorization | Basic 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 |
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
- Webhook events reference - The list of events, each linking to its payload reference
- Webhook routing - Sending different events to different endpoints
- Pre-processing functions - Filtering or reshaping a payload before delivery
- Queue management - Watching and draining the notify queue
- Webhooks API - Managing routes programmatically