Delivery Webhooks
PostMTA delivers real-time event notifications to your systems via HTTPS webhooks. When a message is delivered, bounced, complained about, deferred, or rejected, PostMTA POSTs a signed JSON payload to your configured endpoint within seconds of the MTA event. This page covers the full webhook contract: payload structure, HMAC verification, idempotency guarantees, retry policy, and dead-letter queue handling.
Webhook Delivery
When a delivery event occurs, PostMTA makes an HTTP POST to your registered webhook URL with a JSON body describing the event. The request includes several signed headers used for verification.
Request Headers
| Header | Value | Description |
|---|---|---|
Content-Type | application/json | Always application/json |
X-PMH-Signature | sha256=<hex digest> | HMAC-SHA256 of timestamp + payload |
X-PMH-Event-Type | Event type string | e.g., message.delivered |
X-PMH-Event-ID | Event UUID | Unique per event; use for deduplication |
X-PMH-Timestamp | Unix epoch (seconds) | Unix timestamp of signing time |
User-Agent | PostMTA-Webhook/1.0 | Identifies PostMTA as the sender |
Delivered Event Payload
POST /your-webhook-endpoint HTTP/1.1
Host: yourapp.example.com
Content-Type: application/json
X-PMH-Signature: sha256=7d12d9e3b1a6f8c3...
X-PMH-Event-Type: message.delivered
X-PMH-Event-ID: evt_01HX9P7N3M4Q6R8T2V4W6Y8
X-PMH-Timestamp: 1752859260
User-Agent: PostMTA-Webhook/1.0
Content-Length: 412
{
"event_id": "evt_01HX9P7N3M4Q6R8T2V4W6Y8",
"event_type": "message.delivered",
"occurred_at": "2026-07-18T10:05:23Z",
"message": {
"msg_id": "msg_01HX5K9P7N3M4Q6R8T2V4W6Y8",
"message_id_header": "",
"from": {
"address": "hello@example.com",
"name": "Example Inc"
},
"to": [{"address": "recipient@gmail.com", "mailbox": "recipient"}],
"subject": "Your July Statement"
},
"delivery": {
"mta_status": "250 OK",
"mta_response": "Message accepted for delivery",
"delivered_at": "2026-07-18T10:05:23Z",
"smtp_remote_ip": "142.250.80.19",
"smtp_remote_mta": "gmail-smtp-in.l.google.com"
},
"subuser_id": "sub_usr_01HX..."
} HMAC-SHA256 Verification
Every webhook request is signed using HMAC-SHA256 with a secret unique to your webhook endpoint. The signature covers the Unix timestamp and the raw JSON body, separated by a period. This prevents both payload tampering and replay attacks.
The verification algorithm (Node.js / Express example):
// Node.js / Express HMAC-SHA256 verification middleware
const crypto = require('crypto');
function verifyPostMTAWebhook(req, res, next) {
const secret = process.env.WEBHOOK_SECRET; // pmta_whsec_...
const signatureHeader = req.get('X-PMH-Signature');
if (!signatureHeader) {
return res.status(401).json({ error: 'Missing signature header' });
}
const [scheme, digest] = signatureHeader.split('=');
if (scheme !== 'sha256') {
return res.status(401).json({ error: 'Unsupported signature scheme' });
}
const timestampHeader = req.get('X-PMH-Timestamp');
if (!timestampHeader) {
return res.status(401).json({ error: 'Missing timestamp header' });
}
const timestamp = parseInt(timestampHeader, 10);
const ageSeconds = Date.now() / 1000 - timestamp;
if (ageSeconds > 300) { // 5-minute tolerance
return res.status(401).json({ error: 'Webhook payload too old' });
}
const payload = JSON.stringify(req.body);
const signedPayload = timestampHeader + '.' + payload;
const expectedDigest = crypto
.createHmac('sha256', secret)
.update(signedPayload, 'utf8')
.digest('hex');
const digestBuffer = Buffer.from(digest, 'hex');
const expectedBuffer = Buffer.from(expectedDigest, 'hex');
if (!crypto.timingSafeEqual(digestBuffer, expectedBuffer)) {
return res.status(401).json({ error: 'Invalid signature' });
}
next();
}
app.post(
'/webhooks/postmta',
verifyPostMTAWebhook,
express.json(),
(req, res) => {
const event = req.body;
switch (event.event_type) {
case 'message.delivered':
handleDelivery(event);
break;
case 'message.bounced':
handleBounce(event);
break;
case 'message.complained':
handleComplaint(event);
break;
default:
console.log('Unhandled event type:', event.event_type);
}
res.status(200).json({ received: true });
}
);crypto.timingSafeEqual() (or equivalent) to compare the computed and received digests. String equality (===) is vulnerable to timing attacks that can allow an attacker to forge valid signatures.Idempotency via event_id Deduplication
Each event carries a globally unique event_id (a KSUID). PostMTA guarantees that no event with the same event_id will ever be delivered more than once. Your endpoint should record the event_id of each processed event to guard against double-processing if your system issues a retry before acknowledging receipt.
Recommended idempotency pattern:
1. Extract event_id from X-PMH-Event-ID header (or body.event_id).
2. Check if event_id exists in your local processed-events store.
3. If exists: return HTTP 200 immediately (already processed).
4. If not: process the event, store event_id, return HTTP 200.Event Type Payloads
message.bounced
Fired when the recipient MTA rejects a message after multiple delivery attempts or immediately upon connection. Hard bounces permanently suppress the recipient; soft bounces trigger retry.
{
"event_id": "evt_01HXAB3C5D7E9F1G4H6I8J0K2",
"event_type": "message.bounced",
"occurred_at": "2026-07-18T10:06:01Z",
"message": {
"msg_id": "msg_01HX5K9P7N3M4Q6R8T2V4W6Y8",
"from": {"address": "hello@example.com"},
"to": [{"address": "unknown@recipient.example.com"}],
"subject": "Your July Statement"
},
"bounce": {
"bounce_class": "hard",
"bounce_code": "550 5.1.1",
"enhanced_code": "5.1.1",
"diagnostic": "User unknown",
"mta_status": "550",
"bounced_at": "2026-07-18T10:06:01Z"
},
"subuser_id": "sub_usr_01HX..."
}message.complained
Fired when a recipient clicks the "Report Spam" button and the ISP delivers an FBL report to PostMTA. The complained address is automatically added to the subuser's suppression list.
{
"event_id": "evt_01HXC5D7E9F2G4H6I8J0K2L4",
"event_type": "message.complained",
"occurred_at": "2026-07-18T10:07:15Z",
"message": {
"msg_id": "msg_01HX5K9P7N3M4Q6R8T2V4W6Y8",
"from": {"address": "hello@example.com"},
"to": [{"address": "recipient@yahoo.com"}],
"subject": "Your July Statement"
},
"complaint": {
"feedback_type": "abuse",
"complained_at": "2026-07-18T10:07:15Z",
"x_google_agsi": "1a2b3c...",
"subuser_suppressed": true
},
"subuser_id": "sub_usr_01HX..."
}message.deferred
Fired each time a message receives a temporary failure (4xx SMTP response) and is requeued for retry. This event fires on every deferral, not just the first. Watch deferred.deferred_count to detect messages approaching DLQ saturation.
{
"event_id": "evt_01HXDE7F9G0H2I4J6K8L0M2N4",
"event_type": "message.deferred",
"occurred_at": "2026-07-18T10:04:55Z",
"message": {
"msg_id": "msg_01HX5K9P7N3M4Q6R8T2V4W6Y8",
"from": {"address": "hello@example.com"},
"to": [{"address": "recipient@example.net"}],
"subject": "Your July Statement"
},
"deferred": {
"deferred_count": 2,
"mta_status": "451 4.7.1",
"enhanced_code": "4.7.1",
"diagnostic": "Greylisted - try again later",
"next_retry_at": "2026-07-18T10:34:55Z",
"max_retry_count": 12
},
"subuser_id": "sub_usr_01HX..."
}message.rejected
Fired when PostMTA itself rejects a message at injection time due to a policy violation (SPF failure, DMARC p=reject, shaping block, etc.). Unlike bounced, rejected means the message never reached the recipient MTA.
{
"event_id": "evt_01HXEF9G1H3I5J7K9L0M2N4O6",
"event_type": "message.rejected",
"occurred_at": "2026-07-18T10:03:42Z",
"message": {
"msg_id": "msg_01HX5K9P7N3M4Q6R8T2V4W6Y8",
"from": {"address": "hello@example.com"},
"to": [{"address": "recipient@example.org"}],
"subject": "Your July Statement"
},
"rejected": {
"reason_code": "PMH-POLICY-026",
"reason": "SPF alignment check failed",
"rejected_at": "2026-07-18T10:03:42Z"
},
"subuser_id": "sub_usr_01HX..."
}Retry Policy
If your webhook endpoint returns any HTTP status other than 2xx, or if the connection times out (> 10 seconds), PostMTA retries delivery with exponential backoff and jitter. After 10 failed attempts the message is moved to the Dead Letter Queue.
| Attempt | Base Delay | Cumulative Time | Jitter |
|---|---|---|---|
| 1 | 30 seconds | 30 seconds | +/- 5 seconds |
| 2 | 5 minutes | 5m 30s | +/- 30 seconds |
| 3 | 15 minutes | 20m 30s | +/- 2 minutes |
| 4 | 30 minutes | 50m 30s | +/- 5 minutes |
| 5 | 1 hour | 1h 50m 30s | +/- 10 minutes |
| 6 | 2 hours | 3h 50m 30s | +/- 20 minutes |
| 7 | 4 hours | 7h 50m 30s | +/- 30 minutes |
| 8 | 8 hours | 15h 50m 30s | +/- 1 hour |
| 9 | 24 hours | 39h 50m 30s | +/- 2 hours |
| 10 | 85 hours | ~125 hours | +/- 4 hours |
Total retry window: approximately 125 hours (5 days and 5 hours). After attempt 10, the message is moved to the DLQ.
Dead Letter Queue (DLQ)
Messages that exhaust all 10 retry attempts are moved to the DLQ. The DLQ stores the full message envelope, all retry history, and the final failure reason. You can configure a dedicated DLQ webhook endpoint to receive these events, or inspect the DLQ in the PostMTA dashboard (Sending → Dead Letter Queue).
DLQ Event Payload
POST /your-dlq-webhook HTTP/1.1
Host: yourapp.example.com
Content-Type: application/json
X-PMH-Signature: sha256=9f8e7d6c5b4a3...
X-PMH-Event-Type: message.dlq
X-PMH-Event-ID: evt_01HXDLQ01...
X-PMH-Timestamp: 1753129260
{
"event_id": "evt_01HXDLQ01...",
"event_type": "message.dlq",
"occurred_at": "2026-07-23T10:00:00Z",
"message": {
"msg_id": "msg_01HX5K9P7N3M4Q6R8T2V4W6Y8",
"from": {"address": "hello@example.com"},
"to": [{"address": "recipient@example.net"}],
"subject": "Your July Statement"
},
"dlq": {
"reason": "max_retries_exceeded",
"last_deferred_code": "451 4.7.1",
"deferred_count": 10,
"first_submitted_at": "2026-07-18T10:00:00Z",
"moved_to_dlq_at": "2026-07-23T10:00:00Z",
"dlq_file": "s3://pmh-dlq/prod/2026/07/23/msg_01HXDLQ01.json"
},
"subuser_id": "sub_usr_01HX..."
}Manual Reprocessing
Operators can manually re-queue a DLQ message for delivery using the API:
POST /v1/dlq/msg_01HX5K9P7N3M4Q6R8T2V4W6Y8/reprocess
Authorization: Bearer pmta_live_...
{
"reason": "operator_manual_reprocess",
"suppress_on_retry_failure": true
}Reprocessed messages are subject to the full shaping and suppression pipeline. If the message fails again it returns to the DLQ immediately without retry.
Webhook Logs
The Sending → Webhooks → Logs panel shows the last 7 days of webhook delivery attempts, success/failure status, HTTP response codes, and latency. You can filter by event type, webhook URL, and delivery status.
Troubleshooting
| Symptom | Likely Cause | Resolution |
|---|---|---|
| No webhooks received | Endpoint not reachable / firewall | Verify HTTPS endpoint is publicly accessible; check Webhook Logs for 0 delivery attempts |
| All attempts return 401 | Wrong or missing HMAC secret | Verify secret matches the pmta_whsec_* value from Settings → Webhooks |
| Duplicate deliveries | Your idempotency store is not working | Use event_id as idempotency key; check your processed-events store is write-through |
| Retries never stop | Endpoint returns 200 but message is not processed | PostMTA considers 200 as success; ensure your handler returns 200 only after successful processing |
| DLQ messages never arrive | No DLQ webhook configured | Set a DLQ endpoint in Settings → Webhooks → Dead Letter Queue |