Matrix: M30M33
Open in panel — /app/sending
Operators

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.

Matrix refs: M30 (Webhook Delivery)   M33 (Event Signatures)

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

HeaderValueDescription
Content-Typeapplication/jsonAlways application/json
X-PMH-Signaturesha256=<hex digest>HMAC-SHA256 of timestamp + payload
X-PMH-Event-TypeEvent type stringe.g., message.delivered
X-PMH-Event-IDEvent UUIDUnique per event; use for deduplication
X-PMH-TimestampUnix epoch (seconds)Unix timestamp of signing time
User-AgentPostMTA-Webhook/1.0Identifies 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 });
  }
);
Timing-safe comparison: Always use 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.
Timestamp tolerance: Reject payloads where the timestamp is more than 300 seconds (5 minutes) older or newer than the current server time. This prevents replay attacks where an attacker re-sends a previously captured valid payload.

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.

AttemptBase DelayCumulative TimeJitter
130 seconds30 seconds+/- 5 seconds
25 minutes5m 30s+/- 30 seconds
315 minutes20m 30s+/- 2 minutes
430 minutes50m 30s+/- 5 minutes
51 hour1h 50m 30s+/- 10 minutes
62 hours3h 50m 30s+/- 20 minutes
74 hours7h 50m 30s+/- 30 minutes
88 hours15h 50m 30s+/- 1 hour
924 hours39h 50m 30s+/- 2 hours
1085 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.

Webhook timeout: PostMTA waits a maximum of 10 seconds for your endpoint to respond. If your handler performs async work (database writes, queueing), respond 200 immediately and process asynchronously.

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

SymptomLikely CauseResolution
No webhooks receivedEndpoint not reachable / firewallVerify HTTPS endpoint is publicly accessible; check Webhook Logs for 0 delivery attempts
All attempts return 401Wrong or missing HMAC secretVerify secret matches the pmta_whsec_* value from Settings → Webhooks
Duplicate deliveriesYour idempotency store is not workingUse event_id as idempotency key; check your processed-events store is write-through
Retries never stopEndpoint returns 200 but message is not processedPostMTA considers 200 as success; ensure your handler returns 200 only after successful processing
DLQ messages never arriveNo DLQ webhook configuredSet a DLQ endpoint in Settings → Webhooks → Dead Letter Queue