Events
Export email events in real-time via webhooks or archive logs to S3-compatible storage.
Overview
The Events page provides two ways to export event data from your unMTA cluster:
| Method | Use Case |
|---|---|
| Webhooks | Real-time HTTP notifications for integrations, analytics, and automated workflows |
| Log Shipping | Periodic upload to S3 for compliance, archival, and batch processing |
Both methods deliver the same event data in the same JSON format—choose the one that fits your use case, or use both together.
Event logs on each MTA are automatically deleted after 24 hours. Enable webhooks and/or log shipping to preserve your event data beyond this retention window.
Event Types
unMTA generates events throughout the email delivery lifecycle:
| Event | Description |
|---|---|
| Reception | Message accepted by unMTA for delivery |
| Delivery | Message successfully delivered to the recipient's mail server |
| Bounce | Permanent delivery failure (5xx SMTP response) |
| TransientFailure | Temporary delivery failure (4xx SMTP response, will retry) |
| Expiration | Message exceeded maximum lifetime in queue |
| AdminBounce | Message bounced via admin API |
| OOB | Out-of-band bounce received after initial acceptance |
| Feedback | ARF feedback report (spam complaint from recipient) |
| Rejection | SMTP rejection during session (e.g., authentication failure) |
Event Payload
Each event is a JSON object. The example below shows a Delivery event, which includes the most fields:
{
"type": "Delivery",
"id": "d7f8a9b0c1d2e3f4",
"sender": "d7f8a9b0c1d2e3f4@c-abc123.unmta.net",
"recipient": "user@example.com",
"queue": "example.com",
"site": "example.com",
"peer_address": {
"name": "mx.example.com",
"addr": "192.0.2.1"
},
"response": {
"code": 250,
"content": "OK"
},
"timestamp": 1706000000,
"created": 1706000000,
"num_attempts": 1,
"bounce_classification": "Uncategorized",
"egress_pool": "default",
"egress_source": "default-source",
"delivery_protocol": "ESMTP",
"meta": {
"from": "sender@example.com",
"to": "user@example.com",
"subject": "Your order has shipped",
"message_id": "<abc123@example.com>",
"x_mailer": "MyApp/1.0",
"hostname": "m-12345678.unmta.net",
"authn_id": "api_user"
},
"headers": {},
"session_id": "984851cf-952c-4be6-82fd-375345ab3419",
"nodeid": "a3541223-aa59-4ad2-80ea-8785d23e29e6",
"tls_cipher": "TLS_AES_256_GCM_SHA384",
"tls_protocol_version": "TLSv1.3"
}Key Fields
| Field | Description |
|---|---|
type | Event type (see Event Types table above) |
id | Unique message identifier |
sender | Envelope sender (bounce address) |
recipient | Envelope recipient |
queue | Destination queue |
site | Mail exchanger site |
peer_address | Remote server hostname and IP |
response | SMTP response code and message |
timestamp | Unix timestamp when the event occurred |
created | Unix timestamp when the message was received |
num_attempts | Number of delivery attempts |
bounce_classification | Bounce category (e.g., Uncategorized, AuthenticationFailed) |
meta | Message headers captured at reception |
session_id | SMTP session identifier |
nodeid | MTA node that processed the event |
Event-Specific Fields
Different event types include additional fields:
Reception events:
reception_protocol— How the message was received (ESMTP, HTTP)size— Message size in bytes
Delivery, Bounce, and TransientFailure events:
delivery_protocol— Protocol used for delivery (ESMTP)egress_pool— Outbound IP pool usedegress_source— Specific outbound source
Feedback events:
feedback_report— ARF report details
Rejection events:
- Most identification fields (
id,sender,recipient,queue) may be empty bounce_classificationindicates the rejection reason
Captured Headers
The following headers are captured in the meta field:
from,to,cc,reply_tosubject,message_idin_reply_to,referenceslist_id,list_unsubscribe,list_unsubscribe_post- All custom
X-*headers
System Meta Fields
In addition to captured headers, unMTA adds the following system-generated fields to meta:
| Field | Description |
|---|---|
hostname | Hostname of the MTA node that processed the message (e.g., m-12345678.unmta.net) |
authn_id | Authenticated identity used to submit the message |
Webhooks
Webhooks deliver events to your HTTP endpoint in real-time. Use webhooks when you need immediate notification of email events—for example, to update a CRM when an email bounces or to trigger workflows when messages are delivered.
How Webhooks Work
- Events are collected as they occur
- Events are batched (up to 500 per request) for efficiency
- Your endpoint receives a JSON array of event objects
- unMTA retries on failure with exponential backoff
Multiple Webhook Destinations
Each cluster supports multiple webhook destinations. This lets you route events to different endpoints based on the message — for example, sending transactional events to one system and marketing events to another.
Each destination has:
- A user-defined name for your reference
- A unique 6-character ID (e.g.
abc123) used to route messages at send time - Its own URL and signing secret
- An optional default flag — if set, this destination receives events for any message that doesn't specify a destination
Routing Events to a Destination
Add the X-UNMTA-Event-Dest header to your message at injection time with the destination's 6-character ID:
X-UNMTA-Event-Dest: abc123All lifecycle events for that message (Delivery, Bounce, TransientFailure, Expiration) are sent to the specified destination. OOB bounces and ARF feedback reports are also routed back to the correct destination via the encoded return-path.
If no header is present: events go to the destination marked as default.
If no default is configured and no header is set: no webhook is sent for that message.
Configuring Webhook Destinations
- Navigate to the Events page from the sidebar
- In the Webhooks section, click Add webhook
- Enter a name and your HTTPS endpoint URL
- Optionally enable Set as default to receive events from messages with no explicit destination
- Click Save
- Open the destination to copy the ID (used in
X-UNMTA-Event-Dest) and secret (used for signature verification)
You can add as many destinations as needed. To temporarily stop sending to a destination without deleting it, use the Disable webhook option in the actions menu.
Each destination's signing secret is auto-generated and cannot be changed. Copy it when you create the destination and store it securely.
URL Requirements
- Must be a valid URL with
http://orhttps://protocol - Cannot be localhost, 127.0.0.1, or ::1
- Cannot use private IP ranges (10.x.x.x, 172.16-31.x.x, 192.168.x.x)
- Cannot use local domains (.local, .lan, .internal)
Webhook Request Format
POST /your-endpoint HTTP/1.1
Host: your-server.com
Content-Type: application/json
User-Agent: unMTA-Webhooks/1.0
X-Webhook-Id: a1b2c3d4e5f6...
X-Webhook-Timestamp: 1706000000
X-Webhook-Signature: v1=abc123def456...
[
{"type": "Delivery", "id": "msg1", ...},
{"type": "Bounce", "id": "msg2", ...}
]Webhook Authentication
Every webhook request includes a cryptographic signature so you can verify it came from unMTA. Each destination is signed with its own secret.
| Header | Description |
|---|---|
X-Webhook-Id | Unique identifier for this batch (SHA256 hash of payload) |
X-Webhook-Timestamp | Unix timestamp when the request was sent |
X-Webhook-Signature | HMAC-SHA256 signature in format v1=<hex> |
The signature is computed as:
signing_string = webhook_id + "." + timestamp + "." + payload
signature = HMAC-SHA256(secret, signing_string)Always verify webhook signatures before processing events. This prevents attackers from sending fake events to your endpoint.
Idempotent Consumers
Webhook consumers must be designed to handle duplicate events. Use the X-Webhook-Id header to deduplicate.
If one webhook destination is unreachable or returns errors, the entire event batch is retried — including events destined for healthy endpoints that already received them successfully. This means a working endpoint may receive the same event multiple times until all destinations in the batch succeed or retries are exhausted.
Recommended Architecture for Many Destinations
The multi-webhook feature is designed for a small number of distinct integrations (e.g., a monitoring system, a transactional app, a marketing platform). It is not intended for per-end-user fan-out.
If you need to distribute events to many downstream consumers (e.g., a reseller or ESP routing events to dozens of end users), we recommend a single default webhook destination pointing to a central event server you control. That server can then fan out events internally with full control over retry logic, buffering, and per-consumer delivery.
This avoids:
- Batch fragmentation — a 500-event batch splitting into dozens of individual HTTP requests
- Retry amplification — one slow or unreachable consumer causing duplicates across all other consumers
Verifying Webhook Signatures
$payload = file_get_contents('php://input');
$webhookId = $_SERVER['HTTP_X_WEBHOOK_ID'];
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'];
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'];
$signingString = $webhookId . '.' . $timestamp . '.' . $payload;
$expectedSignature = 'v1=' . hash_hmac('sha256', $signingString, $secret);
if (!hash_equals($expectedSignature, $signature)) {
http_response_code(401);
exit('Invalid signature');
}
$events = json_decode($payload, true);
foreach ($events as $event) {
// Process each event
}const crypto = require('crypto');
app.post('/webhook', (req, res) => {
const payload = JSON.stringify(req.body);
const webhookId = req.headers['x-webhook-id'];
const timestamp = req.headers['x-webhook-timestamp'];
const signature = req.headers['x-webhook-signature'];
const signingString = `${webhookId}.${timestamp}.${payload}`;
const expectedSignature = 'v1=' + crypto
.createHmac('sha256', secret)
.update(signingString)
.digest('hex');
if (!crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(signature)
)) {
return res.status(401).send('Invalid signature');
}
for (const event of req.body) {
// Process each event
}
res.status(200).send('OK');
});import hmac
import hashlib
import json
from flask import Flask, request
app = Flask(**name**)
@app.route('/webhook', methods=['POST'])
def webhook():
payload = request.get_data(as_text=True)
webhook_id = request.headers.get('X-Webhook-Id')
timestamp = request.headers.get('X-Webhook-Timestamp')
signature = request.headers.get('X-Webhook-Signature')
signing_string = f"{webhook_id}.{timestamp}.{payload}"
expected_signature = 'v1=' + hmac.new(
secret.encode(),
signing_string.encode(),
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected_signature, signature):
return 'Invalid signature', 401
events = json.loads(payload)
for event in events:
# Process each event
pass
return 'OK', 200package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
)
func webhookHandler(w http.ResponseWriter, r *http.Request) {
payload, _ := io.ReadAll(r.Body)
webhookId := r.Header.Get("X-Webhook-Id")
timestamp := r.Header.Get("X-Webhook-Timestamp")
signature := r.Header.Get("X-Webhook-Signature")
signingString := webhookId + "." + timestamp + "." + string(payload)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signingString))
expectedSignature := "v1=" + hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expectedSignature), []byte(signature)) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
var events []map[string]interface{}
json.Unmarshal(payload, &events)
for _, event := range events {
// Process each event
}
w.WriteHeader(http.StatusOK)
}Handling Failures
When your endpoint returns a 4xx or 5xx status code, unMTA will retry the webhook with exponential backoff. Because retries affect the entire batch across all destinations, your endpoint must be idempotent:
- Use the
X-Webhook-Idheader to deduplicate requests - Store processed webhook IDs and skip duplicates
- Return 200 OK as soon as you've received the events
Log Shipping
Log shipping periodically uploads event logs to S3-compatible storage. Use log shipping when you need to archive events for compliance, run batch analytics, or integrate with data warehouses.
Supported Storage Services
AWS S3 — Native Amazon S3 with region selection
S3-Compatible Services:
- MinIO
- DigitalOcean Spaces
- Backblaze B2
- Cloudflare R2
- Wasabi
- Any S3-compatible API
Upload Frequency
| Frequency | Use Case |
|---|---|
| 5 minutes | Near real-time analysis, high-volume monitoring |
| 10 minutes | |
| 15 minutes | |
| 30 minutes | Balance of timeliness and efficiency |
| 1 hour | Standard operational logging |
| 3 hours | |
| 6 hours | |
| 12 hours | |
| 24 hours | Daily archival, compliance retention |
Logs are uploaded as compressed JSON files (zstd compression).
Configuring Log Shipping
- Navigate to the Events page from the sidebar
- In the S3 Log Shipping section, click Edit Log Shipping
- Enable the Enable S3 log shipping toggle
- Select your service type:
- AWS S3 — Enter your region
- S3 Compatible — Enter your endpoint URL
- Enter your bucket name
- Optionally enter a path prefix (e.g.,
logs/unmta/) - Enter your access key and secret key
- Select your upload frequency
- Click Test Connection to verify access
- Click Save
S3 Bucket Requirements
Your S3 credentials need the following permissions:
| Permission | Required | Purpose |
|---|---|---|
s3:PutObject | Yes | Upload log files |
s3:DeleteObject | No | Clean up test files during connection test |
Example IAM Policy (AWS):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:PutObject"],
"Resource": "arn:aws:s3:::your-bucket-name/*"
}
]
}Log File Format
Log files are uploaded with the following characteristics:
- Format: JSON, zstd compressed
- Structure: One event per line (JSONL format)
- Organization: Files organized by timestamp in your bucket
- Content: Same event payload structure as webhooks
Best Practices
For Webhooks
- Always verify signatures — Each destination has its own secret; reject requests with invalid signatures to prevent spoofing
- Respond quickly — Return 200 OK immediately after receiving; process events asynchronously
- Handle retries — Use
X-Webhook-Idfor deduplication; a retry affects all destinations in a batch, so any endpoint may receive duplicates - Use HTTPS — Encrypt webhook traffic to protect event data in transit
- Monitor failures — A single failing destination can cause retries across the entire batch; keep all endpoints healthy
- Use a central fan-out for many consumers — See Recommended Architecture for Many Destinations
For Log Shipping
- Choose appropriate frequency — Higher frequencies provide fresher data but create more small files
- Use path prefixes — Organize logs by environment or purpose (e.g.,
production/unmta/) - Set retention policies — Configure S3 lifecycle rules to manage storage costs
- Secure credentials — Use IAM roles with minimal required permissions