EmailEngine webhooks let your application react when a mailbox changes instead of repeatedly asking whether anything happened. With the right endpoint, event filtering, signature checks, and duplicate handling, you can turn incoming mail, send outcomes, account failures, and engagement signals into reliable application workflows. (learn.emailengine.app)
What EmailEngine webhooks are—and what they are not
EmailEngine is a self-hosted email API that connects to Gmail, Microsoft 365, and IMAP/SMTP mailboxes, then exposes a unified HTTP API for application code. Its webhook system sends HTTP POST notifications to an endpoint you control when relevant account, mailbox, message, delivery, or tracking events occur. (emailengine.app)
That distinction matters: a webhook is an event notification, not necessarily the complete source of truth for every business workflow. A messageNew payload can contain useful message data, but your application should still be prepared to fetch the message through EmailEngine's API when it needs more fields, the raw RFC 5322 source, or an attachment. The message API uses the account and message identifiers to retrieve those resources. (learn.emailengine.app)
For most teams, the goal is one of these:
- Create or update a CRM ticket when a customer replies.
- Sync a shared inbox into an internal support interface.
- Mark an outbound message as submitted or permanently failed.
- Detect bounces and complaints and suppress future sends.
- Alert an administrator when an OAuth token expires or a mailbox disconnects.
- Track opens, clicks, unsubscribe requests, or subscribe requests when EmailEngine's tracking features are in use.
The important operational model is simple: EmailEngine detects an event, queues a delivery job, POSTs JSON to your endpoint, and expects a successful 2xx response promptly. Its documentation specifies a response within five seconds and recommends asynchronous processing, while failed webhook deliveries are retried with exponential backoff. (learn.emailengine.app)
When to use webhooks instead of polling
Polling means calling an endpoint such as a message-list API every 30 seconds, 60 seconds, or five minutes to discover changes. It is sometimes appropriate for a reconciliation job, but it is a poor primary mechanism for a support inbox or event-driven product: it introduces a delay, wastes requests when nothing changed, and makes it easy to mishandle paging or race conditions.
EmailEngine webhooks push a notification when the change is observed, so your service can queue work immediately. EmailEngine specifically positions webhooks as the alternative to polling for mailbox events, message changes, and delivery status. (learn.emailengine.app)
A dependable architecture uses both, but for different jobs:
- Webhooks drive normal-time processing. A
messageNewevent creates or updates a conversation quickly. - A periodic reconciliation job repairs gaps. It compares the mailbox or a known time window with your database after outages, deployment mistakes, or downstream failures.
- Your database records processing state. Store event IDs, message IDs, account IDs, and timestamps so retries and replays do not create duplicate tickets.
This is not an admission that webhooks are unreliable. It is a design choice: webhooks are delivered asynchronously and may be retried, so a receiver must be safe when an event arrives more than once. EmailEngine provides a unique event identifier in an HTTP header specifically for idempotency. (learn.emailengine.app)
The EmailEngine webhook event model
Every EmailEngine webhook uses a shared top-level envelope. The documented universal fields are serviceUrl, event, account, date, and data; mailbox and message events can also include fields such as path and specialUse. The event ID is deliberately not part of the JSON body: it is sent in X-EE-Wh-Event-Id. (learn.emailengine.app)
A representative payload looks like this:
{
"serviceUrl": "https://emailengine.example.internal",
"event": "messageNew",
"account": "support-inbox",
"date": "2025-01-15T10:30:00.000Z",
"path": "INBOX",
"specialUse": "\\Inbox",
"data": {
"id": "AAAAAQAACnA",
"subject": "Re: Billing question",
"from": {
"address": "customer@example.com",
"name": "Avery Customer"
}
}
}
The exact data shape is event-specific and can vary by provider and configuration. Design handlers around the top-level event value, validate the fields needed for that event, and tolerate additional fields instead of failing because EmailEngine includes a new optional property. (learn.emailengine.app)
Events worth handling first
Start narrow. Subscribing to every event makes early debugging noisier and increases the chance that you build behavior around events your product does not need. EmailEngine supports a webhookEvents setting so you can request only relevant event names. (learn.emailengine.app)
For a shared-inbox workflow, begin with:
accountInitialized— the mailbox connected and completed its initial synchronization.authenticationErrorandconnectError— the account needs attention or cannot communicate with its provider.messageNew— a newly detected message.messageUpdated— changes such as read/unread state, flags, or labels.messageDeleted— a message was deleted or moved to trash.mailboxReset— mailbox state changed enough that a local cache should be rebuilt.
For an outbound sending workflow, add:
messageSent— EmailEngine successfully handed the queued message to SMTP or the Gmail/Outlook API.messageFailed— permanent delivery failure after retry attempts are exhausted.messageBounce,messageComplaint, andmessageDeliveryErrorwhere your workflow needs deliverability handling.
EmailEngine's reference lists these message, account, mailbox, tracking, and subscription-related event families. messageSent means acceptance by the configured sending server or provider API; it is not proof that the recipient read the message or that final recipient delivery succeeded. (learn.emailengine.app)
Understand initialization bursts and reset events
Do not treat every webhook as a single, isolated user action. When a new account is synchronized, EmailEngine can emit mailboxNew for folders it discovers, so a newly connected mailbox can produce a burst of folder events. Account setup typically progresses through accountAdded, successful authentication, and accountInitialized; use accountInitialized as the practical signal that the initial sync is complete. (learn.emailengine.app)
A mailboxReset is another event to take seriously. For IMAP folders, a UIDVALIDITY change invalidates assumptions based on the old message UID space; EmailEngine clears its folder state, resynchronizes, and then emits new-message events for rediscovered messages. In response, mark your local cache or search index for a controlled resync rather than attempting to patch it with the old IDs. (learn.emailengine.app)
Configure an EmailEngine webhook endpoint
You can configure the global webhook URL through the EmailEngine admin UI or settings API. In the UI, go to Configuration → Webhooks, enable webhooks, enter the HTTPS endpoint, select the events you want, and save. The setting can also be applied with POST /v1/settings. (learn.emailengine.app)
Here is a minimal API configuration. Replace the host and bearer token with values for your own self-hosted instance:
curl -X POST "https://emailengine.example.com/v1/settings" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"webhooks": "https://app.example.com/webhooks/emailengine",
"webhooksEnabled": true,
"webhookEvents": [
"accountInitialized",
"authenticationError",
"connectError",
"messageNew",
"messageSent",
"messageFailed",
"messageBounce"
]
}'
The documented settings endpoint accepts the webhook URL and optional webhookEvents array; if you do not supply an event filter, the default is all events. API requests use bearer-token authentication. (learn.emailengine.app)
Before pointing EmailEngine at production, make sure the receiver meets these requirements:
- It is reachable from the network where EmailEngine runs.
- It accepts HTTP
POSTrequests withapplication/json. - It terminates TLS correctly when exposed beyond a trusted private network.
- It returns
2xxin under five seconds. - It queues slow work instead of performing database migrations, AI analysis, attachment processing, or third-party API calls before responding.
EmailEngine is designed to be self-hosted and binds to localhost by default, commonly behind a TLS-terminating reverse proxy. That means a local URL that works in a browser on the EmailEngine server may not be reachable by an external SaaS receiver, and an external URL may fail because of firewall, DNS, egress, or proxy configuration. (emailengine.app)
Build a secure receiver with idempotency
The most useful production rule is: verify, persist, acknowledge, then process. Verification protects the endpoint, persistence makes retries safe, acknowledgement prevents unnecessary retry pressure, and background processing keeps your webhook response fast.
EmailEngine includes diagnostic headers on webhook POSTs. The key ones are X-EE-Wh-Event-Id, X-EE-Wh-Attempts-Made, X-EE-Wh-Queued-Time, and X-EE-Wh-Signature. The signature is an HMAC-SHA256 value encoded as base64url and calculated over the JSON body using EENGINE_SECRET. (learn.emailengine.app)
A Node.js receiver example
This Express example preserves the request bytes for signature verification, uses a timing-safe comparison, and stores the event ID before queuing work. The database and queue methods are placeholders; their behavior is the important part.
import crypto from "node:crypto";
import express from "express";
const app = express();
app.post(
"/webhooks/emailengine",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.get("X-EE-Wh-Signature") || "";
const eventId = req.get("X-EE-Wh-Event-Id");
if (!eventId) {
return res.status(400).json({ error: "Missing event ID" });
}
const expected = crypto
.createHmac("sha256", process.env.EENGINE_SECRET)
.update(req.body)
.digest("base64url");
const valid =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!valid) {
return res.status(401).json({ error: "Invalid signature" });
}
const event = JSON.parse(req.body.toString("utf8"));
// Insert must enforce a UNIQUE constraint on event_id.
const inserted = await db.webhookEvents.insertIfAbsent({
eventId,
eventType: event.event,
account: event.account,
payload: event,
receivedAt: new Date()
});
// Tell EmailEngine the delivery succeeded only after durable persistence.
res.status(202).json({ accepted: true });
if (inserted) {
await jobs.enqueue("process-emailengine-event", { eventId });
}
}
);
EmailEngine's reference demonstrates the same HMAC-SHA256 verification concept and states that the event ID is stable across retries. Preserving the original raw body before parsing is a defensive implementation detail: signature verification must use the same byte sequence EmailEngine signed, not a reconstructed object whose serialization could differ. (learn.emailengine.app)
Why idempotency cannot be optional
A non-2xx response, timeout, deployment interruption, or connection failure can lead to another delivery attempt. EmailEngine documents automatic webhook retries with exponential backoff and exposes the delivery-attempt count in X-EE-Wh-Attempts-Made. Therefore, “process every POST exactly once” is not a safe assumption. (learn.emailengine.app)
Use X-EE-Wh-Event-Id as the first deduplication key. Add a unique database constraint such as UNIQUE(event_id) and make the insertion atomic. Then add domain-level protections as well: for example, a support ticket system may use the account plus an email thread identifier or message ID to ensure a duplicate inbound message cannot create two conversations if a downstream worker retries after doing partial work.
Do not deduplicate only on subject line, sender address, or timestamp. Two real emails can share all three. Use EmailEngine's event ID for delivery idempotency and stable mail identifiers for business-level correlation.
Worked example: turn inbound replies into support tickets
Imagine a product with a shared mailbox named support-inbox. The application should create a ticket for a new customer email, append later replies to the same ticket, and alert an operator if the connected mailbox stops authenticating.
Step 1: subscribe only to needed events
Configure messageNew, accountInitialized, authenticationError, and connectError. Do not initially subscribe to messageUpdated, tracking, bounce, or folder events unless the product will use them.
{
"webhooks": "https://app.example.com/webhooks/emailengine",
"webhooksEnabled": true,
"webhookEvents": [
"accountInitialized",
"authenticationError",
"connectError",
"messageNew"
]
}
Step 2: receive and persist the event
When the receiver gets a verified messageNew event, it writes an event row keyed by X-EE-Wh-Event-Id, returns 202 Accepted, and enqueues an asynchronous job. At this stage, it should not send Slack alerts, call an AI classifier, download files, or create a ticket synchronously.
Step 3: filter out the wrong folder or message class
Your job reads event.path, event.specialUse, and the message data. A support workflow usually wants messages in the inbox rather than copies saved in Sent, All Mail, Trash, or a spam folder. Gmail labels and IMAP folders differ, so use the special-use information and your own account rules instead of assuming every provider uses an INBOX path identically. EmailEngine exposes path, specialUse, and provider-specific message fields in its webhook model. (learn.emailengine.app)
A simple application rule could be:
if (event.event !== "messageNew") return;
if (event.specialUse && event.specialUse !== "\\Inbox") return;
if (event.path && event.path !== "INBOX") return;
Treat this as an example, not a universal Gmail rule. If the support team intentionally receives mail in a custom folder, adjust the allowlist. The right test is whether the filter matches the folders your connected accounts actually monitor.
Step 4: fetch authoritative message data when needed
Use event.account and event.data.id to fetch the full message if the webhook body does not contain the fields your ticket system requires. Then extract sender, recipients, subject, plain-text content, HTML content, message headers, attachment metadata, and reply/thread identifiers according to your application model. EmailEngine provides endpoints to list messages, retrieve an individual message, retrieve source, and download attachments. (learn.emailengine.app)
Step 5: make ticket creation idempotent
Create a processed_messages record with a unique key based on the mailbox account and EmailEngine message ID. If a row already exists, stop. If it does not, determine whether the message belongs to an existing conversation; otherwise create a new ticket. Save the resulting ticket ID alongside the mailbox and message identifiers.
This second uniqueness layer handles a realistic failure: the worker might create a ticket successfully and then crash before marking the webhook event complete. On retry, the event-level record says it was previously received, while the message-level record says the ticket already exists.
Step 6: define success tests
Your integration worked when all of the following are true:
- EmailEngine reports the account as initialized after its first synchronization.
- A test email sent from an external mailbox causes a
messageNewPOST. - Your receiver logs a valid signature and stores one event ID.
- The API can retrieve the referenced message.
- One ticket appears, with the expected sender and content.
- Replaying the same event ID or retrying the worker creates no extra ticket.
- An invalid signature receives
401and creates no database rows.
EmailEngine's debugging guidance suggests testing a newly connected mailbox and then sending an external email; it notes that a new-message notification can arrive within roughly 10–60 seconds in that test flow. (blog.emailengine.app)
Route different EmailEngine webhooks to different systems
A single endpoint is ideal for a small application, because it centralizes verification, persistence, observability, and retries. As the product grows, different event categories may deserve different consumers: operational account failures might go to an alerting handler, inbound messages to a ticketing worker, and engagement signals to analytics.
EmailEngine webhook routes support filters and mapping functions. Multiple enabled routes can match the same event, and each matching route receives its own copy; default delivery still follows the configured account-specific or global destination behavior. (learn.emailengine.app)
A route filter for a dedicated support handler could be:
if (payload.account === "support-inbox" && payload.event === "messageNew") {
return true;
}
return false;
A filter that reduces noise to inbox messages could be:
const inInbox =
payload.path === "INBOX" ||
payload.data?.labels?.includes("\\Inbox");
return payload.event === "messageNew" && inInbox;
Routing is useful, but do not use it as a substitute for your receiver's security checks. Every destination that receives a webhook must verify the signature and enforce idempotency independently. Also consider the privacy implications of mapping: a transformation that forwards full email content to chat, analytics, or automation services may expose customer data unnecessarily.
For implementation details such as account endpoints, message retrieval, and authentication, keep the team's integration notes aligned with the email API setup reference rather than copying endpoint assumptions into several services.
Common EmailEngine webhook failures and their fixes
The endpoint receives nothing
First, remove your application from the equation. Put a temporary request inspector or controlled test listener at the webhook URL, enable all events, and trigger a known event. If it receives nothing, check the configured URL, DNS resolution from the EmailEngine host, outbound firewall rules, proxy routing, TLS certificates, and whether the endpoint is accidentally reachable only from a developer laptop. EmailEngine's own debugging procedure recommends this external-listener test before investigating application code. (blog.emailengine.app)
Then prove that EmailEngine sees the mailbox event. For an inbound-email test, look in the mailbox with a normal client and list messages through the EmailEngine API. If the message is absent from EmailEngine's API response, the issue is account configuration, credentials, OAuth scope, or the wrong mailbox—not webhook delivery. (blog.emailengine.app)
EmailEngine keeps retrying
A retry normally means your endpoint timed out, returned a non-2xx status, or could not be reached. Log the HTTP status, response time, event ID, attempt count, event type, and account for every request. Do not log entire email bodies by default, because mailbox webhooks may contain personal or confidential information.
EmailEngine uses a BullMQ-backed notify queue for webhook delivery. Its Bull Board interface can show waiting, delayed, failed, and completed jobs, while the debugging guide recommends retaining completed and failed queue entries—such as 100 entries—during investigation so the error stack, headers, and payload remain available. (learn.emailengine.app)
Signature verification fails even though the secret looks correct
The usual causes are using a different EENGINE_SECRET than the EmailEngine process uses, parsing and re-serializing the JSON before calculating the HMAC, reading the wrong header name, or comparing a standard Base64 output with the documented base64url signature format. Check that the secret persists across restarts: EmailEngine uses EENGINE_SECRET for stored-secret encryption and webhook HMAC signing, and changing it can also make previously encrypted credentials unreadable. (learn.emailengine.app)
One email creates duplicate records
This indicates that the receiver acknowledges or processes a retried event without a durable idempotency record. Add a unique database key on X-EE-Wh-Event-Id; then make downstream ticket, CRM, and billing operations idempotent on their own identifiers. Do not fix duplicates by turning off retries—retries are how a webhook producer avoids silently losing events.
The webhook arrives but data is incomplete
Do not assume the webhook must contain every field needed by your application. Configure only the optional notification data you actually need, then fetch the complete message or its source via the EmailEngine API in the worker. This keeps the receiver fast and lets your business logic work from an explicit, current API read when necessary. (learn.emailengine.app)
Security and privacy checklist
Webhooks carry data about email accounts and may include message content, sender addresses, recipients, subjects, links, and attachments. Treat a webhook endpoint as a privileged integration surface.
- Use HTTPS for publicly reachable endpoints and restrict network access where possible.
- Verify
X-EE-Wh-Signaturebefore parsing the event into business workflows. - Keep
EENGINE_SECRETin a secrets manager or protected environment configuration, never in source control. - Enforce a unique constraint on
X-EE-Wh-Event-Id. - Return
2xxonly after durable event persistence, not merely after receiving bytes in memory. - Put a size limit and request timeout on the receiver that suit your environment.
- Log identifiers and outcomes, not full message bodies or authorization tokens.
- Restrict access to queue dashboards and operational logs.
- Review webhook-route mapping functions before forwarding email data to third parties.
EmailEngine documentation warns that, without EENGINE_SECRET, passwords, OAuth tokens, and application secrets stored in Redis are unencrypted. This is separate from webhook verification but reinforces why the secret should be generated once, protected, and retained consistently. (learn.emailengine.app)
A production readiness checklist
Before shipping EmailEngine webhooks, verify these behaviors in a non-production mailbox:
- Your receiver accepts an HTTPS POST and responds within five seconds.
- The HMAC check rejects altered bodies and incorrect signatures.
- A valid event ID is stored exactly once, even when you send the same payload twice.
- Your background worker can fetch a message by account and message ID.
- A newly connected mailbox's initialization burst does not create noisy user notifications.
- A
mailboxResettriggers a safe cache or index rebuild plan. - A failed endpoint is visible in queue monitoring and can be diagnosed without exposing email content broadly.
- Account authentication and connection errors reach an alerting path.
- A reconciliation job can repair a deliberately deleted local record.
- Your deployment preserves the same
EENGINE_SECRETand has a Redis backup and monitoring plan.
A good webhook integration is not defined by receiving the first POST. It is defined by continuing to produce one correct business outcome when the network times out, the producer retries, a worker crashes halfway through, a mailbox is reset, or an account's credentials stop working.
FAQ
How do I enable EmailEngine webhooks?
In the EmailEngine UI, open Configuration → Webhooks, enable webhooks, set the target URL, choose events, and save. Alternatively, send POST /v1/settings with webhooks, webhooksEnabled, and optionally webhookEvents. (learn.emailengine.app)
Does messageSent mean the recipient received the email?
No. It means EmailEngine successfully submitted the queued message to the configured SMTP server or Gmail/Outlook API. A later bounce, complaint, or tracking event is a separate signal, and an open event is not guaranteed because mail clients can block tracking pixels. (learn.emailengine.app)
How do I prevent duplicate webhook processing?
Store X-EE-Wh-Event-Id in a database column with a unique constraint before processing the event. EmailEngine keeps that ID the same for retries, so repeated deliveries can be acknowledged without repeating the business action. (learn.emailengine.app)
Why is EmailEngine retrying my webhook?
Your endpoint likely timed out, returned a non-2xx response, or could not be reached. Inspect the notify/webhooks queue in Bull Board, preserve failed jobs during debugging, and check status codes, TLS, DNS, firewall rules, and signature-validation failures. (learn.emailengine.app)
Can EmailEngine send different events to different URLs?
Yes. Webhook routes can filter by account, event type, or payload content, and can transform a payload before delivery. Multiple routes may receive the same event, so each destination should independently validate signatures and deduplicate deliveries. (learn.emailengine.app)