Webhooks API
The Webhooks API allows you to configure real-time event notifications from EmailEngine to your application. Instead of polling for changes, webhooks push notifications to your endpoint when events occur.
Overview
Webhook System Architecture
EmailEngine's webhook system provides:
- Real-time notifications: Instant event delivery
- Event filtering: Subscribe to specific event types
- Automatic retries: Failed deliveries are retried with exponential backoff
- Signature verification: Secure webhook payload authentication
- Multiple routes: Configure different endpoints for different accounts
Event-Driven Integration
Webhooks enable event-driven architecture:
Benefits:
- No polling overhead
- Instant notification
- Scalable to high-volume accounts
- Reduced API calls
Webhooks vs Polling
| Aspect | Webhooks | Polling |
|---|---|---|
| Latency | Instant | Depends on interval |
| Efficiency | High (push) | Low (pull) |
| Server Load | Low | High |
| Complexity | Medium | Low |
| Reliability | Auto-retry | Manual |
Webhook Management
1. Register Webhook
Configure a webhook endpoint to receive events.
Endpoint: POST /v1/settings
Request Parameters:
| Field | Type | Required | Description |
|---|---|---|---|
webhooks | string | Yes | Webhook endpoint URL |
webhookEvents | array | No | Event types to receive (default: all) |
Example:
Pseudo code:
// Register webhook endpoint
response = HTTP_POST(
"http://localhost:3000/v1/settings",
{
headers: {
"Authorization": "Bearer YOUR_ACCESS_TOKEN",
"Content-Type": "application/json"
},
body: {
webhooks: "https://your-app.com/webhook",
webhookEvents: ["messageNew", "messageSent", "messageDeliveryError"]
}
}
)
result = PARSE_JSON(response.body)
PRINT("Webhook configured: " + result.success)
- Python
- cURL
response = requests.post(
'http://localhost:3000/v1/settings',
headers={
'Authorization': 'Bearer YOUR_ACCESS_TOKEN',
'Content-Type': 'application/json'
},
json={
'webhooks': 'https://your-app.com/webhook',
'webhookEvents': ['messageNew', 'messageSent']
}
)
result = response.json()
print(f"Webhook configured: {result['success']}")
curl -X POST http://localhost:3000/v1/settings \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"webhooks": "https://your-app.com/webhook",
"webhookEvents": ["messageNew", "messageSent"]
}'
Response:
{
"success": true
}
2. List Webhook Routes
Retrieve all configured webhook routes.
Endpoint: GET /v1/webhookRoutes
Example:
Pseudo code:
// List all webhook routes
response = HTTP_GET(
"http://localhost:3000/v1/webhookRoutes",
{
headers: {
"Authorization": "Bearer YOUR_ACCESS_TOKEN"
}
}
)
data = PARSE_JSON(response.body)
for each webhook in data.webhooks {
PRINT(webhook.id + ": " + webhook.targetUrl)
}
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",
"tcount": 123
}
]
}
3. Get Webhook Route
Retrieve details of a specific webhook route.
Endpoint: GET /v1/webhookRoutes/webhookRoute/:webhookRoute
Example:
Pseudo code:
// Get specific webhook route details
routeId = "route_123abc"
response = HTTP_GET(
"http://localhost:3000/v1/webhookRoutes/webhookRoute/" + routeId,
{
headers: {
"Authorization": "Bearer YOUR_ACCESS_TOKEN"
}
}
)
route = PARSE_JSON(response.body)
PRINT("Webhook URL: " + route.targetUrl)
PRINT("Events: " + route.events)
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,
"content": {
"fn": "return true;",
"map": "payload.ts = Date.now(); return payload;"
}
}
4. Update Webhook Route
Update an existing webhook route configuration.
Endpoint: PUT /v1/webhookRoutes/webhookRoute/:webhookRoute
Example:
Pseudo code:
// Update webhook route configuration
routeId = "route_123abc"
response = HTTP_PUT(
"http://localhost:3000/v1/webhookRoutes/webhookRoute/" + routeId,
{
headers: {
"Authorization": "Bearer YOUR_ACCESS_TOKEN",
"Content-Type": "application/json"
},
body: {
events: ["messageNew", "messageDeleted", "messageSent"],
enabled: true
}
}
)
result = PARSE_JSON(response.body)
PRINT("Webhook updated: " + result.success)
5. Delete Webhook Route
Remove a webhook route.
Endpoint: DELETE /v1/webhookRoutes/webhookRoute/:webhookRoute
Example:
Pseudo code:
// Delete webhook route
routeId = "route_123abc"
response = HTTP_DELETE(
"http://localhost:3000/v1/webhookRoutes/webhookRoute/" + routeId,
{
headers: {
"Authorization": "Bearer YOUR_ACCESS_TOKEN"
}
}
)
result = PARSE_JSON(response.body)
PRINT("Webhook deleted: " + result.success)
Webhook Configuration
Target URL
The webhook endpoint must:
- Use HTTPS (HTTP only for development)
- Be publicly accessible
- Respond within 30 seconds
- Return 2xx status code for success
Valid URLs:
https://your-app.com/webhook
https://api.yourservice.com/emailengine/events
https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXX
Event Filters
Subscribe to specific event types:
{
webhooks: 'https://your-app.com/webhook',
webhookEvents: [
'messageNew', // New message received
'messageDeleted', // Message deleted
'messageSent', // Message sent successfully
'messageDeliveryError' // Send failed
]
}
Subscribe to all events:
{
webhooks: 'https://your-app.com/webhook'
// webhookEvents omitted = all events
}
Custom Headers
Add custom headers to webhook requests using an array of key-value objects:
{
webhooks: 'https://your-app.com/webhook',
webhooksCustomHeaders: [
{ key: 'X-API-Key', value: 'your-secret-key' },
{ key: 'X-Source', value: 'emailengine' }
]
}
Authentication
Bearer Token:
{
webhooks: 'https://your-app.com/webhook',
webhooksCustomHeaders: [
{ key: 'Authorization', value: 'Bearer YOUR_SECRET_TOKEN' }
]
}
Basic Auth: Include credentials in URL (not recommended for production):
{
webhooks: 'https://user:password@your-app.com/webhook'
}
Webhook Payload
Common Payload Structure
All webhooks follow this structure:
{
"event": "messageNew",
"account": "user@example.com",
"date": "2025-01-15T10:30:00.000Z",
"data": {
/* event-specific data */
}
}
Fields:
| Field | Type | Description |
|---|---|---|
event | string | Event type (e.g., "messageNew") |
account | string | Account identifier |
date | string | ISO timestamp of event |
data | object | Event-specific payload |
Event-Specific Fields
Each event type includes specific data in the data field. See Webhook Events Reference for complete details.
Example - messageNew:
{
"event": "messageNew",
"account": "user@example.com",
"date": "2025-01-15T10:30:00.000Z",
"data": {
"id": "AAAABAABNc",
"uid": 12345,
"path": "INBOX",
"subject": "New Email",
"from": {
"name": "John Doe",
"address": "john@example.com"
},
"date": "2025-01-15T10:30:00.000Z",
"unseen": true
}
}
Retry Metadata
EmailEngine includes retry information in HTTP headers (not in the payload):
| Header | Description |
|---|---|
X-EE-Wh-Id | Unique webhook job ID |
X-EE-Wh-Attempts-Made | Number of delivery attempts made |
X-EE-Wh-Queued-Time | Time since webhook was queued (e.g., "5s") |
X-EE-Wh-Event-Id | Event ID (if available) |
Event Types Overview
Complete list of webhook events (see Webhook Events Reference for detailed payloads):
Account Events
| Event | Trigger |
|---|---|
accountAdded | Account registered |
accountDeleted | Account deleted |
authenticationError | Authentication failed |
connectError | Connection to server failed |
accountInitialized | Account first connected |
Message Events
| Event | Trigger |
|---|---|
messageNew | New message received |
messageDeleted | Message deleted from mailbox |
messageUpdated | Message flags changed |
messageMissing | Message disappeared from mailbox |
Mailbox Events
| Event | Trigger |
|---|---|
mailboxNew | New folder created |
mailboxDeleted | Folder deleted |
mailboxReset | Mailbox sync was reset |
Sending Events
| Event | Trigger |
|---|---|
messageSent | Message sent successfully |
messageDeliveryError | Sending failed |
messageBounce | Bounce notification received |
Security
Webhook Signatures
EmailEngine signs webhook payloads for verification.
Signature Header:
X-EE-Wh-Signature: <base64url-encoded-hmac>
Verify Signature:
Pseudo code:
// Pseudo code - implement HMAC-SHA256 verification in your language
function verifyWebhook(payload, signature, secret) {
// Create HMAC with SHA256
hmac = CREATE_HMAC("sha256", secret)
hmac.UPDATE(payload) // raw request body as string
expectedSignature = hmac.DIGEST_BASE64URL()
// Constant-time comparison to prevent timing attacks
return CONSTANT_TIME_COMPARE(signature, expectedSignature)
}
// Example webhook handler
function handleWebhook(request, response) {
signature = request.headers["x-ee-wh-signature"]
secret = GET_SERVICE_SECRET() // Retrieved from EmailEngine settings
if NOT verifyWebhook(request.rawBody, signature, secret) {
return HTTP_RESPONSE(401, { error: "Invalid signature" })
}
// Process webhook
PRINT("Event: " + request.body.event)
return HTTP_RESPONSE(200, { success: true })
}
- Python
import hmac
import hashlib
import base64
def verify_webhook(payload, signature, secret):
# Compute HMAC-SHA256 and encode as base64url (no padding)
computed = hmac.new(
secret.encode(),
payload, # raw bytes
hashlib.sha256
).digest()
expected = base64.urlsafe_b64encode(computed).rstrip(b'=').decode()
return hmac.compare_digest(signature, expected)
# Flask example
@app.route('/webhook', methods=['POST'])
def webhook():
signature = request.headers.get('X-EE-Wh-Signature')
payload = request.get_data() # raw bytes
# Note: The secret is the serviceSecret from EmailEngine settings
secret = os.environ['WEBHOOK_SECRET']
if not verify_webhook(payload, signature, secret):
return jsonify({'error': 'Invalid signature'}), 401
event = request.json
print(f"Event: {event['event']}")
return jsonify({'success': True})
IP Whitelisting
Restrict webhook access to EmailEngine's IP:
Nginx:
location /webhook {
allow 1.2.3.4; # EmailEngine IP
deny all;
proxy_pass http://localhost:3000;
}
Pseudo code:
// IP whitelist middleware - implement in your language/framework
function ipWhitelist(allowedIPs) {
return function middleware(request, response, next) {
clientIP = request.ip
if clientIP NOT IN allowedIPs {
return HTTP_RESPONSE(403, { error: "Forbidden" })
}
next()
}
}
// Apply to webhook endpoint
ROUTE("POST", "/webhook",
ipWhitelist(["1.2.3.4"]),
handleWebhook
)
HTTPS Requirement
Production webhooks should use HTTPS:
- Prevents man-in-the-middle attacks
- Encrypts sensitive payload data
- Required for PCI compliance
Development exception: HTTP allowed for localhost testing only.
Testing Webhooks
Webhook Tailing Feature
Monitor webhooks in real-time via EmailEngine UI:
- Navigate to Settings > Webhooks
- Click "Tail Webhooks"
- See live webhook deliveries
Testing Tools
RequestBin: Create temporary webhook endpoint:
https://requestbin.com/
Webhook.site: Instant webhook URL for testing:
https://webhook.site/
ngrok: Expose local server to internet:
ngrok http 3000
# Use generated URL: https://abc123.ngrok.io/webhook
Local Testing
Simple test endpoint (pseudo code):
// Create basic webhook receiver for testing
CREATE_HTTP_SERVER(port: 3000)
ROUTE("POST", "/webhook", function(request, response) {
PRINT("Webhook received: " + request.body)
PRINT("Event: " + request.body.event)
PRINT("Account: " + request.body.account)
return HTTP_RESPONSE(200, { success: true })
})
START_SERVER()
PRINT("Webhook receiver listening on port 3000")
Test with curl:
curl -X POST http://localhost:3000/webhook \
-H "Content-Type: application/json" \
-d '{
"event": "messageNew",
"account": "test@example.com",
"data": {
"subject": "Test"
}
}'
Debugging Tips
Check webhook logs in EmailEngine:
# View logs with webhook activity
docker logs emailengine | grep webhook
Validate endpoint:
# Test your endpoint is accessible
curl -X POST https://your-app.com/webhook \
-H "Content-Type: application/json" \
-d '{"test": true}'
Common issues:
- Endpoint not accessible (firewall, DNS)
- HTTPS certificate invalid
- Timeout (response > 30s)
- Wrong status code (not 2xx)
- Signature verification failing
Common Patterns
Event Filtering
Process only specific events:
Pseudo code:
// Webhook handler with event filtering
ROUTE("POST", "/webhook", function(request, response) {
event = request.body.event
account = request.body.account
data = request.body.data
switch event {
case "messageNew":
handleNewMessage(account, data)
break
case "messageSent":
handleMessageSent(account, data)
break
case "messageDeliveryError":
handleSendError(account, data)
break
default:
PRINT("Unhandled event: " + event)
}
return HTTP_RESPONSE(200, { success: true })
})
Retry Handling
Implement idempotent webhook processing:
Pseudo code:
// Track processed webhooks to prevent duplicates
processedWebhooks = SET() // or database table
ROUTE("POST", "/webhook", function(request, response) {
event = request.body.event
account = request.body.account
data = request.body.data
// Generate unique ID for this webhook
webhookId = event + "-" + account + "-" + data.id
// Check if already processed
if webhookId IN processedWebhooks {
PRINT("Duplicate webhook, skipping")
return HTTP_RESPONSE(200, { success: true })
}
try {
processWebhook(event, account, data)
processedWebhooks.ADD(webhookId)
return HTTP_RESPONSE(200, { success: true })
} catch error {
PRINT_ERROR("Webhook processing error: " + error)
return HTTP_RESPONSE(500, { error: error.message })
}
})
Idempotency
Use message IDs to prevent duplicate processing:
Pseudo code:
// Handle new message with idempotency check
function handleNewMessage(account, message) {
// Check if message already processed
exists = DATABASE.FIND_ONE({
table: "messages",
where: {
account: account,
messageId: message.id
}
})
if exists {
PRINT("Message already processed")
return
}
// Process new message
processMessage(message)
// Mark as processed
DATABASE.INSERT({
table: "messages",
data: {
account: account,
messageId: message.id,
processedAt: CURRENT_TIMESTAMP()
}
})
}
Queue for Processing
Handle webhooks asynchronously:
Pseudo code:
// Create job queue for webhook processing
queue = CREATE_JOB_QUEUE("webhooks")
// Webhook endpoint - quick response
ROUTE("POST", "/webhook", function(request, response) {
// Add to queue
queue.ADD_JOB(request.body)
// Respond immediately (< 30 seconds)
return HTTP_RESPONSE(200, { success: true })
})
// Worker process (runs separately)
queue.PROCESS(function(job) {
event = job.data.event
account = job.data.account
data = job.data.data
processWebhook(event, account, data)
})
Error Handling
Implement robust error handling:
Pseudo code:
// Webhook handler with comprehensive error handling
ROUTE("POST", "/webhook", function(request, response) {
try {
event = request.body.event
account = request.body.account
data = request.body.data
// Validate payload
if NOT event OR NOT account {
return HTTP_RESPONSE(400, {
error: "Invalid webhook payload"
})
}
// Process webhook
processWebhook(event, account, data)
return HTTP_RESPONSE(200, { success: true })
} catch error {
PRINT_ERROR("Webhook error: " + error)
// Return 500 to trigger EmailEngine retry
return HTTP_RESPONSE(500, {
error: "Processing failed",
message: error.message
})
}
})
Complete Example
Full webhook receiver with all best practices:
Pseudo code:
// Complete webhook receiver implementation
// Webhook secret for signature verification (serviceSecret from EmailEngine settings)
WEBHOOK_SECRET = GET_SERVICE_SECRET()
// Verify webhook signature middleware
function verifySignature(request, response, next) {
signature = request.headers["x-ee-wh-signature"]
if NOT signature {
return HTTP_RESPONSE(401, { error: "Missing signature" })
}
hmac = CREATE_HMAC("sha256", WEBHOOK_SECRET)
hmac.UPDATE(request.rawBody) // raw request body bytes
expected = hmac.DIGEST_BASE64URL()
if NOT CONSTANT_TIME_COMPARE(signature, expected) {
return HTTP_RESPONSE(401, { error: "Invalid signature" })
}
next()
}
// Track processed webhooks for idempotency
processed = SET()
// Webhook endpoint with middleware
ROUTE("POST", "/webhook",
MIDDLEWARE: [JSON_PARSER, verifySignature],
HANDLER: function(request, response) {
event = request.body.event
account = request.body.account
data = request.body.data
// Generate unique ID
webhookId = event + "-" + account + "-" + (data.id OR CURRENT_TIMESTAMP())
// Check if already processed
if webhookId IN processed {
return HTTP_RESPONSE(200, { success: true, duplicate: true })
}
try {
// Process event
switch event {
case "messageNew":
handleNewMessage(account, data)
break
case "messageSent":
handleMessageSent(account, data)
break
case "messageDeliveryError":
handleSendError(account, data)
break
default:
PRINT("Unhandled event: " + event)
}
// Mark as processed
processed.ADD(webhookId)
return HTTP_RESPONSE(200, { success: true })
} catch error {
PRINT_ERROR("Webhook processing error: " + error)
return HTTP_RESPONSE(500, { error: error.message })
}
}
)
// Event handler functions
function handleNewMessage(account, message) {
PRINT("New message from " + message.from.address)
PRINT("Subject: " + message.subject)
// Process message...
}
function handleMessageSent(account, data) {
PRINT("Message sent: " + data.messageId)
// Update database...
}
function handleSendError(account, data) {
PRINT_ERROR("Send failed: " + data.error)
// Alert admin...
}
// Start server
START_SERVER(port: 3000)
PRINT("Webhook receiver running on port 3000")