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, plus engagement events when open and click-tracking is enabled for a message:
| 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) |
| Open | Tracking pixel loaded by the recipient (opt-in per message) |
| Click | Tracked link clicked by the recipient (opt-in per message) |
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
Open and Click events have a different, tracking-specific shape (no meta, no peer_address, etc.) — see Open and Click Events below.
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 |
Open and Click Events
Open and Click events are emitted when a recipient loads a tracking pixel or clicks a tracked link, for messages that opted in to tracking with X-UNMTA-Track-Opens: 1 or X-UNMTA-Track-Clicks: 1. They use a different, intentionally flatter shape than lifecycle events — they originate at the tracking endpoint, not at the MTA's delivery pipeline, so they don't carry peer_address, meta, response, etc.
Open Event
{
"type": "Open",
"id": "d7f8a9b0c1d2e3f4",
"recipient": "user@example.com",
"timestamp": 1706000042,
"event_time": "2026-01-23T12:00:42Z",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 ...",
"remote_ip": "203.0.113.42"
}Click Event
{
"type": "Click",
"id": "d7f8a9b0c1d2e3f4",
"recipient": "user@example.com",
"url": "https://example.com/promo?utm_source=email",
"timestamp": 1706000117,
"event_time": "2026-01-23T12:01:57Z",
"user_agent": "Mozilla/5.0 ...",
"remote_ip": "203.0.113.42"
}Bot-Classified Example
When the pixel or click came from a scanner, AI crawler, or mailbox proxy, the event gains a nested bot object:
{
"type": "Open",
"id": "d7f8a9b0c1d2e3f4",
"recipient": "user@example.com",
"timestamp": 1706000042,
"event_time": "2026-01-23T12:00:42Z",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X) AppleWebKit/605.1.15 ...",
"remote_ip": "17.58.32.42",
"bot": { "kind": "proxy", "source": "apple" }
}Open/Click Fields
| Field | Description |
|---|---|
type | "Open" or "Click" |
id | Message ID of the original send (matches the id on the corresponding Reception/Delivery) |
recipient | Envelope recipient the pixel/link was minted for |
url | (Click only) The original URL the recipient clicked |
timestamp | Unix timestamp when the pixel/click was loaded |
event_time | Same moment as timestamp, as an ISO-8601 string |
user_agent | Value of the User-Agent header on the pixel/click request |
remote_ip | IP that loaded the pixel or clicked the link |
bot | (optional) Bot classification. A nested object with kind ("proxy" or "automation") and source (fine-grained provider — "apple", "google", "security", "ai", "crawler", "prefetch", etc.). Present only when the event was identified as non-human. See Bot Signatures for the complete enum and recommended filtering patterns. |
The bot field is absent for human traffic — treat its presence, not its truthiness, as the signal. Bucket engagement metrics on bot.kind (or the absence of bot); use bot.source only for diagnostic slicing. The agent never suppresses Open/Click events; filtering is a consumer-side decision.
Routing
Open and Click events route the same way lifecycle events do: to the webhook destination named by X-UNMTA-Event-Dest on the original send, or the default destination if the header wasn't set. If no default is configured and no header was set, the event is not sent.
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) — as well as any Open and Click events generated by tracking — 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 should handle duplicate events. Use the X-Webhook-Id header to deduplicate.
Each destination is retried independently — a failure at one endpoint does not cause re-delivery to any other endpoint that already received the batch. Duplicates at a given endpoint come only from retries against that same endpoint (for example, when your server returns a 5xx and unMTA retries the same batch).
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 batch to that destination with exponential backoff. Retries are scoped to the destination that failed — other destinations in the same batch are not re-delivered. To stay safe against retries against your own endpoint, make your consumer 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; retries against your endpoint can produce duplicate deliveries - Use HTTPS — Encrypt webhook traffic to protect event data in transit
- Monitor failures — Each destination is retried independently, but a chronically failing endpoint will fall behind and eventually exhaust its retry budget
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