postmark-webhooks

Public
0

Repository: activecampaign/postmark-skills

Log in or sign up to clone this skill.

A
activecampaign
Imported Mar 3, 2026
Scan failed

Description

Use when setting up Postmark outbound webhooks for tracking email delivery, bounces, opens, clicks, spam complaints, or subscription changes — includes webhook configuration, endpoint verification, per-webhook statistics, retry behavior, payload handling, and security.

Details

License MIT
Metadata
author
postmark
version
1.1.0

Skill Files

Download .zip
SKILL.md
# Postmark Webhooks

## Overview

Postmark webhooks deliver real-time event data to your endpoint via HTTP POST. Use webhooks to track what happens after you send an email.

This skill covers **outbound** webhooks — the six event types below. Inbound email webhooks run on separate infrastructure with different retry behavior; see `postmark-inbound` for those.

| Event | Trigger | Common Use |
|-------|---------|------------|
| **Delivery** | Email accepted by recipient server | Confirm delivery, update status |
| **Bounce** | Email rejected by recipient server | Clean lists, alert support |
| **SpamComplaint** | Recipient marked as spam | Remove from lists, investigate |
| **Open** | Recipient opened email (tracking pixel) | Engagement analytics |
| **Click** | Recipient clicked a tracked link | Engagement analytics, conversion tracking |
| **SubscriptionChange** | Recipient unsubscribed | Update preferences, comply with regulations |

## Quick Start

1. **Create a webhook** via API or [Postmark dashboard](https://account.postmarkapp.com) (Server → Webhooks)
2. **Set your endpoint URL** — must accept HTTP POST and return 200
3. **Select event triggers** — choose which events to receive
4. **Pass verification** — Postmark tests your endpoint for every enabled event type on create and edit; each must return HTTP 200 before the webhook is saved as verified
5. **Handle payloads** — parse the JSON body for each event type
6. **Respond with 200** — acknowledge receipt immediately, then process

## Webhook API

### Endpoints

| Endpoint | Method | Description |
|----------|--------|-------------|
| `/webhooks` | `GET` | List all webhooks for a message stream |
| `/webhooks/{webhookid}` | `GET` | Get a specific webhook |
| `/webhooks` | `POST` | Create a webhook |
| `/webhooks/{webhookid}` | `PUT` | Update a webhook |
| `/webhooks/{webhookid}` | `DELETE` | Delete a webhook |
| `/webhooks/{Id}/verify` | `POST` | Run endpoint verification on demand |
| `/webhooks/{Id}/statistics` | `GET` | Delivery statistics for one webhook (rolling 24 hours) |

### Create a Webhook

```javascript
const postmark = require('postmark');
const client = new postmark.ServerClient(process.env.POSTMARK_SERVER_TOKEN);

const webhook = await client.createWebhook({
  Url: 'https://yourdomain.com/webhooks/postmark',
  MessageStream: 'outbound',
  Verify: true, // default — test every enabled trigger before saving
  HttpAuth: {
    Username: 'webhook-user',
    Password: 'webhook-secret'
  },
  HttpHeaders: [
    { Name: 'X-Custom-Header', Value: 'my-value' }
  ],
  Triggers: {
    Open: { Enabled: true, PostFirstOpenOnly: false },
    Click: { Enabled: true },
    Delivery: { Enabled: true },
    Bounce: { Enabled: true, IncludeContent: true },
    SpamComplaint: { Enabled: true, IncludeContent: true },
    SubscriptionChange: { Enabled: true }
  }
});

console.log('Webhook created:', webhook.ID);
```

### cURL

```bash
curl "https://api.postmarkapp.com/webhooks" \
  -X POST \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -H "X-Postmark-Server-Token: $POSTMARK_SERVER_TOKEN" \
  -d '{
    "Url": "https://yourdomain.com/webhooks/postmark",
    "MessageStream": "outbound",
    "Verify": true,
    "Triggers": {
      "Open": { "Enabled": true, "PostFirstOpenOnly": false },
      "Click": { "Enabled": true },
      "Delivery": { "Enabled": true },
      "Bounce": { "Enabled": true, "IncludeContent": true },
      "SpamComplaint": { "Enabled": true, "IncludeContent": true },
      "SubscriptionChange": { "Enabled": true }
    }
  }'
```

### Trigger Options

| Trigger | Options |
|---------|---------|
| **Open** | `Enabled`, `PostFirstOpenOnly` (true = only first open per recipient) |
| **Click** | `Enabled` |
| **Delivery** | `Enabled` |
| **Bounce** | `Enabled`, `IncludeContent` (include original email content) |
| **SpamComplaint** | `Enabled`, `IncludeContent` |
| **SubscriptionChange** | `Enabled` |

## Endpoint Verification

When a webhook is created or edited, Postmark tests the endpoint for **every enabled event type**. Each test must return HTTP 200 before the webhook is saved as verified.

### The `Verify` field

| Field | Type | Default | Behavior |
|-------|------|---------|----------|
| `Verify` | boolean | `true` | `true` — test every enabled trigger before saving. `false` — save the webhook **without** testing; it stays unverified and receives no events until it passes verification later. |

If verification fails on create or edit, the save is rejected with **HTTP 422** and **error code 1364**. Nothing is saved on create; on edit the existing webhook is left unchanged.

### Verify on demand

`POST /webhooks/{Id}/verify` runs the check on demand.

**A 200 response means the check RAN — not that it passed.** Read the `Success` field to determine the result: `Success` can be `false` inside an HTTP 200 response. Never treat the status code as "verified."

The response body reports per-trigger results — `Id`, `Url`, `Success`, `Results[]` (each with `TriggerType`, `Success`, `StatusCode`, `Message`), and a summary `Message` such as `"4/5 triggers verified successfully"`. Top-level `Success` is `true` only if **every** enabled trigger returned 200.

See [references/webhook-setup.md](references/webhook-setup.md) for the full response shape and field reference.

### Persistent failure pauses one event type

If an event type keeps failing across many events, Postmark marks **that event type** unverified and pauses delivery for it until the endpoint is fixed and re-verified.

This is tracked **per event type** — Bounce failing does not pause Delivery or Open. Existing webhooks start as verified at launch cutover.

## Retries and Failure Handling

Retries apply to **outbound** webhooks. Inbound email webhooks use a different, longer schedule — see `postmark-inbound`. Do not apply one schedule to the other.

### Which failures are retried

| Response | Classification | Behavior |
|----------|---------------|----------|
| `5xx` | Temporary | Retried |
| `408` Request Timeout | Temporary | Retried |
| `429` Too Many Requests | Temporary | Retried |
| Network timeout | Temporary | Retried |
| Every other `4xx` (400, 401, 403, 404, 405, 410, 422) | **Permanent** | Dropped on the first attempt — no retries |
| `3xx` | Temporary | Redirects are followed by default, so Postmark classifies the **final** status. A bare `3xx` is retried, not dropped |

### Retry schedule

When a failure is retryable, a webhook gets **up to 9 retries over approximately 72 minutes**. Delivery runs in tiers, so a failing webhook works through two rungs in order:

| Rung | Retries | Intervals | Elapsed |
|------|---------|-----------|---------|
| Standard | 3 | 1 min, 5 min, 15 min | 21 min |
| Backoff (entered after standard is exhausted) | 6 | 1 min, 5 min, 10 min, 10 min, 10 min, 15 min | 51 min |
| **Total** | **9** | | **~72 min** |

After the 9th retry the event is dropped permanently. The gap does not widen monotonically — it resets to 1 minute when the backoff rung starts.

The same 9-retry ladder applies to **every outbound event type** and cannot be customized per webhook or per event type.

Each attempt carries an **`X-PM-Retries-Remaining`** header. It starts at **9** on the first failure and counts down across both rungs — a `9` early in an incident is expected, not a misconfiguration.

## Webhook Statistics

`GET /webhooks/{Id}/statistics` returns delivery statistics for **one** webhook, keyed by ID. There is no all-webhooks or per-stream variant and no status filter. The window is a fixed rolling **24 hours** — not filterable in v1.

The response contains:

| Field | Contents |
|-------|----------|
| `WebhookId`, `Url` | Which webhook the statistics describe |
| `Statuses` | Verification status per event type (`verified` / `unverified`) |
| `TimeRange` | `StartTime`, `EndTime`, `Hours` — always a rolling 24 hours |
| `Metrics` | Top-level aggregate: `TotalRequests`, `SuccessCount`, `FailureCount`, `RetryCount`, `SuccessRate`, `SlowCount`, `VerySlowCount`, `AverageTerminalResponseTimeMs`, `AverageRetryResponseTimeMs` |
| `MetricsByTrigger` | The same metric fields, broken out per event type |

- `AverageRetryResponseTimeMs` is `null` when a trigger had no retries.
- The same metric fields appear in both `Metrics` and each `MetricsByTrigger` entry.

Use `Statuses` to spot a paused event type and per-trigger `SuccessRate` to find which one is failing.

See [references/webhook-setup.md](references/webhook-setup.md) for the full response shape and field reference.

## Webhook Payloads

All payloads include `RecordType`, `MessageID`, `MessageStream`, and `Metadata` (from the original send). Use `RecordType` to route events:

```javascript
app.post('/webhooks/postmark', (req, res) => {
  res.sendStatus(200); // respond immediately

  const event = req.body;
  switch (event.RecordType) {
    case 'Delivery':          handleDelivery(event); break;
    case 'Bounce':            handleBounce(event); break;
    case 'SpamComplaint':     handleSpamComplaint(event); break;
    case 'Open':              handleOpen(event); break;
    case 'Click':             handleClick(event); break;
    case 'SubscriptionChange': handleSubscriptionChange(event); break;
  }
});
```

### Bounce Types

| Type | Code | Action |
|------|------|--------|
| `HardBounce` | 1 | Permanent — remove address from all lists |
| `SoftBounce` | 4096 | Temporary — Postmark retries; monitor |
| `Transient` | 2 | Temporary — retry may succeed |
| `SpamNotification` | 512 | Marked as spam at recipient's server |
| `Blocked` | 16 | Blocked by recipient server |
| `DMARCPolicy` | 100000 | Rejected due to DMARC policy |

See [references/payload-examples.md](references/payload-examples.md) for full JSON payloads for all 6 event types.

See [references/handler-examples.md](references/handler-examples.md) for complete Node.js and Python implementations, async processing, deduplication, and metadata correlation.

## Idempotency

A retry can redeliver an event your endpoint already processed — for example if it processed the event but responded too slowly. **Handlers must be idempotent.**

Deduplicate on the retry-stable **`X-PM-Webhook-Trace-Id`** header, falling back to `MessageID` when the header is absent. Respond 200 first, then process.

See [references/handler-examples.md](references/handler-examples.md) for Node.js and Python deduplication implementations.

## Security

Postmark does **not** sign webhook requests — there is no HMAC signature or signing header to verify. Do not write or trust signature-verification logic in a webhook handler.

Protect your endpoint with HTTP Basic Auth (credentials in the webhook URL, or the `HttpAuth` field), IP allowlisting, and payload-shape validation.

See [references/security.md](references/security.md) for full implementation examples.

## Bounce Management

Use the Bounces API and Suppression Management API alongside webhooks for comprehensive bounce handling.

See [references/bounce-management.md](references/bounce-management.md) for the Bounces API, suppression management, and bounce rate thresholds.

## Webhook Management

See [references/webhook-setup.md](references/webhook-setup.md) for list, update, delete, verification, statistics, and retry schedule details.

## Common Mistakes

| Mistake | Fix |
|---------|-----|
| Not responding 200 | Always return HTTP 200 — even if processing fails. Process asynchronously. |
| Slow webhook handling | Respond 200 immediately, then process in background (queue, worker) |
| Treating a 200 from `/verify` as "verified" | A 200 means the check ran. Read the `Success` field — it can be `false` in a 200 response |
| Verifying an HMAC signature | Postmark does not sign webhooks — there is no signature header. Use Basic Auth, IP allowlisting, and payload-shape validation |
| No authentication | Use HTTP Basic Auth or custom headers to verify webhook source |
| Ignoring bounce types | Handle `HardBounce` differently from `SoftBounce` — hard bounces require permanent suppression |
| Not handling partial data | Some fields may be missing — always check for presence before accessing |
| Non-idempotent handlers | Retries can redeliver an already-processed event — dedupe on `X-PM-Webhook-Trace-Id`, falling back to `MessageID` |
| Expecting a 4xx to be retried | Only 5xx, 408, 429, and network timeouts are retried. Every other 4xx is permanent and dropped on the first attempt |
| Assuming `X-PM-Retries-Remaining: 9` is a bug | 9 is the correct starting value — the ladder is 3 standard retries plus 6 backoff retries |
| Missing MessageStream filter | Specify `MessageStream` when creating webhooks to avoid cross-stream events |
| Not tracking metadata | Include `Metadata` when sending to correlate webhook events with your records |

## Notes

- Webhooks are configured per message stream — create separate webhooks for `outbound` and `broadcast`
- Always respond HTTP 200 immediately — process webhook data asynchronously
- Postmark tests every enabled event type on create and edit; each must return 200 before the webhook is saved as verified. `"Verify": false` skips the test and leaves the webhook unverified, receiving no events
- A failed verification on create or edit is rejected with HTTP 422 and error code 1364
- Outbound retries: up to **9 retries** over ~72 minutes, in two rungs — standard (1 min, 5 min, 15 min) then backoff (1 min, 5 min, 10 min, 10 min, 10 min, 15 min). Same ladder for every outbound event type; not customizable. Only 5xx, 408, 429, and network timeouts are retried; every other 4xx is permanent and dropped on the first attempt. `X-PM-Retries-Remaining` starts at 9 and counts down across both rungs. Inbound webhooks use a different schedule — see `postmark-inbound`
- Persistent failure on one event type marks that event type unverified and pauses delivery for it only — other event types keep flowing
- Handlers must be idempotent — dedupe on `X-PM-Webhook-Trace-Id`, falling back to `MessageID`
- Use `MessageID` to correlate webhook events with sent emails
- `Metadata` from the original send is included in all webhook payloads
- Open tracking requires a tracking pixel in HTML — it does not work with plain text emails
- Click tracking requires `TrackLinks` to be enabled on the sent email
- Bounce webhooks fire for bounces and blocks — check the `Type` field to distinguish
- Spam complaints, unsubscribes, and manual deactivations have their own event types (not Bounce)
- Individual open/click data is stored for 45 days; aggregated statistics are stored indefinitely
references/bounce-management.md Reference
# Bounce Management and Suppression

## Delivery Statistics

Get a summary of bounce and delivery activity for your server:

```javascript
const postmark = require('postmark');
const client = new postmark.ServerClient(process.env.POSTMARK_SERVER_TOKEN);

const stats = await client.getDeliveryStatistics();
console.log('Inactive emails:', stats.InactiveMails);
stats.Bounces.forEach(b => console.log(`${b.Name}: ${b.Count}`));
```

---

## Bounces API

### List Bounces

```javascript
const bounces = await client.getBounces({
  count: 100,
  offset: 0,
  type: 'HardBounce',     // optional filter
  messageStream: 'outbound'
});

bounces.Bounces.forEach(b => {
  console.log(`${b.Email} — ${b.Type} (inactive: ${b.Inactive})`);
});
```

### Get a Single Bounce

```javascript
const bounce = await client.getBounce(bounceId);
console.log(bounce.Description);
console.log(bounce.Details); // raw SMTP response
```

### Reactivate a Bounced Recipient

Use only when a soft bounce was genuinely temporary and the issue is resolved:

```javascript
const result = await client.activateBounce(bounceId);
console.log(result.Message); // "OK"
```

**Do not reactivate hard bounces.** The address does not exist — reactivating results in another bounce.

---

## Suppression Management

Suppressions prevent emails from being sent to specific recipients, managed per message stream.

### List Suppressions

```javascript
const result = await client.getSuppressions('outbound');

result.Suppressions.forEach(s => {
  console.log(`${s.EmailAddress} — ${s.SuppressionReason} (${s.Origin})`);
});
```

### Manually Suppress a Recipient

```javascript
await client.createSuppressions('outbound', {
  Suppressions: [{ EmailAddress: '[email protected]' }]
});
```

### Remove a Suppression

Only remove when a recipient has explicitly re-opted in:

```javascript
await client.deleteSuppressions('outbound', {
  Suppressions: [{ EmailAddress: '[email protected]' }]
});
```

**Never remove suppressions for spam complaints or hard bounces.** Doing so damages sender reputation.

---

## Suppression Reasons

| Reason | Origin | Can Remove? |
|--------|--------|-------------|
| `HardBounce` | Automatic | Only if confirmed deliverable |
| `SpamComplaint` | Automatic | No — never re-add |
| `ManualSuppression` | API or dashboard | Yes, with fresh consent |
| `Unsubscribe` | Recipient action | Only with explicit re-opt-in |

---

## Bounce Handling Strategy

| Bounce Type | Action |
|-------------|--------|
| `HardBounce` | Permanently remove — address does not exist |
| `SoftBounce` | Log and monitor — Postmark retries; don't remove yet |
| `SpamComplaint` | Immediately suppress — never send again |
| `Blocked` | Log and investigate — may indicate content/reputation issue |
| `DMARCPolicy` | Fix SPF/DKIM/DMARC configuration |

## Bounce Rate Thresholds

| Metric | Warning | Critical |
|--------|---------|----------|
| Bounce rate | > 2% | > 4% |
| Spam complaint rate | > 0.04% | > 0.08% |

High bounce rates are typically caused by sending to old or purchased lists, not removing hard bounces promptly, or domain warm-up that is too aggressive.
references/handler-examples.md Reference
# Webhook Handler Examples

## Node.js / Express — Full Handler

```javascript
const express = require('express');
const app = express();
app.use(express.json());

app.post('/webhooks/postmark', (req, res) => {
  // Respond 200 immediately — process asynchronously if needed
  res.sendStatus(200);

  const event = req.body;

  switch (event.RecordType) {
    case 'Delivery':     handleDelivery(event); break;
    case 'Bounce':       handleBounce(event); break;
    case 'SpamComplaint': handleSpamComplaint(event); break;
    case 'Open':         handleOpen(event); break;
    case 'Click':        handleClick(event); break;
    case 'SubscriptionChange': handleSubscriptionChange(event); break;
    default:
      console.log('Unknown event type:', event.RecordType);
  }
});

function handleDelivery(event) {
  console.log(`Delivered to ${event.Recipient} at ${event.DeliveredAt}`);
  // db.emails.update({ messageId: event.MessageID }, { status: 'delivered' });
}

function handleBounce(event) {
  console.log(`Bounce (${event.Type}) for ${event.Email}: ${event.Description}`);

  if (event.Type === 'HardBounce') {
    // Permanently remove — address does not exist
    // db.contacts.update({ email: event.Email }, { status: 'invalid' });
  }

  if (event.Inactive) {
    // Postmark has deactivated this recipient
    console.log(`Recipient ${event.Email} marked inactive by Postmark`);
  }
}

function handleSpamComplaint(event) {
  console.log(`Spam complaint from ${event.Email}`);
  // Permanently suppress — never send again
  // db.suppressions.insert({ email: event.Email, reason: 'spam_complaint' });
}

function handleOpen(event) {
  if (event.FirstOpen) {
    console.log(`First open by ${event.Recipient} on ${event.Platform}`);
  }
  // db.emailEvents.insert({ messageId: event.MessageID, type: 'open' });
}

function handleClick(event) {
  console.log(`Click by ${event.Recipient}: ${event.OriginalLink}`);
  // db.emailEvents.insert({ messageId: event.MessageID, type: 'click', url: event.OriginalLink });
}

function handleSubscriptionChange(event) {
  console.log(`Unsubscribe: ${event.Recipient} (stream: ${event.MessageStream})`);
  if (event.SuppressSending) {
    // Sync to your own system
    // db.contacts.update({ email: event.Recipient }, { subscribed: false });
  }
}
```

## Python / Flask — Full Handler

```python
from flask import Flask, request

app = Flask(__name__)

@app.route('/webhooks/postmark', methods=['POST'])
def handle_webhook():
    event = request.get_json()
    record_type = event.get('RecordType')

    handlers = {
        'Delivery': handle_delivery,
        'Bounce': handle_bounce,
        'SpamComplaint': handle_spam_complaint,
        'Open': handle_open,
        'Click': handle_click,
        'SubscriptionChange': handle_subscription_change,
    }

    handler = handlers.get(record_type)
    if handler:
        handler(event)
    else:
        print(f"Unknown event type: {record_type}")

    return '', 200

def handle_delivery(event):
    print(f"Delivered to {event['Recipient']} at {event['DeliveredAt']}")

def handle_bounce(event):
    bounce_type = event['Type']
    print(f"Bounce ({bounce_type}) for {event['Email']}")
    if bounce_type == 'HardBounce':
        pass  # permanently remove from lists
    if event.get('Inactive'):
        print(f"Recipient {event['Email']} marked inactive by Postmark")

def handle_spam_complaint(event):
    print(f"Spam complaint from {event['Email']}")
    # permanently suppress

def handle_open(event):
    if event.get('FirstOpen'):
        print(f"First open by {event['Recipient']} on {event.get('Platform')}")

def handle_click(event):
    print(f"Click by {event['Recipient']}: {event.get('OriginalLink')}")

def handle_subscription_change(event):
    print(f"Unsubscribe: {event['Recipient']} (stream: {event['MessageStream']})")
    if event.get('SuppressSending'):
        pass  # update your subscription records
```

## Async Processing

For slow operations (database writes, API calls), respond 200 immediately and queue the work:

```javascript
const Queue = require('bull');
const webhookQueue = new Queue('postmark-webhooks', process.env.REDIS_URL);

app.post('/webhooks/postmark', (req, res) => {
  webhookQueue.add(req.body);
  res.sendStatus(200); // respond before processing
});

webhookQueue.process(async (job) => {
  const event = job.data;
  await processWebhookEvent(event);
});
```

## Idempotency and Deduplication

A retry can redeliver an event your endpoint already processed — for example if the endpoint processed the event but responded too slowly. **Handlers must be idempotent.**

Deduplicate on the retry-stable **`X-PM-Webhook-Trace-Id`** header, which stays the same across retries of the same event. Fall back to `MessageID` when the header is absent.

### Node.js / Express

```javascript
// Use Redis or a database in production instead of a Set
const processedEvents = new Set();

function dedupeKey(req) {
  const traceId = req.headers['x-pm-webhook-trace-id'];
  if (traceId) return `trace-${traceId}`;
  return `${req.body.RecordType}-${req.body.MessageID}`; // fallback
}

app.post('/webhooks/postmark', (req, res) => {
  res.sendStatus(200); // respond first, then process

  const key = dedupeKey(req);
  if (processedEvents.has(key)) return; // already handled — do nothing
  processedEvents.add(key);

  processEvent(req.body);
});
```

### Python / Flask

```python
# Use Redis or a database in production instead of a set
processed_events = set()

def dedupe_key(request, event):
    trace_id = request.headers.get('X-PM-Webhook-Trace-Id')
    if trace_id:
        return f"trace-{trace_id}"
    return f"{event.get('RecordType')}-{event.get('MessageID')}"  # fallback

@app.route('/webhooks/postmark', methods=['POST'])
def handle_webhook():
    event = request.get_json()
    key = dedupe_key(request, event)

    if key in processed_events:
        return '', 200  # already handled — acknowledge and do nothing
    processed_events.add(key)

    process_event(event)
    return '', 200
```

Make the write side idempotent too — prefer upserts keyed on the dedupe key over blind inserts, so a redelivery cannot double-count an open or re-suppress a recipient.

### Reading Retry State

Each retry carries an `X-PM-Retries-Remaining` header. Use it for logging and alerting — never as a dedupe key, since it changes on every attempt.

```javascript
app.post('/webhooks/postmark', (req, res) => {
  res.sendStatus(200);

  const retriesRemaining = req.headers['x-pm-retries-remaining'];
  if (retriesRemaining !== undefined) {
    console.warn(`Retry delivery — ${retriesRemaining} retries remaining`);
  }

  // ... dedupe and process
});
```

## Correlating Events with Sent Emails

Include `Metadata` when sending to tie webhook events back to your records:

```javascript
// When sending
await client.sendEmail({
  From: '[email protected]',
  To: customer.email,
  Subject: 'Your order has shipped',
  TextBody: '...',
  MessageStream: 'outbound',
  Metadata: {
    customer_id: customer.id,
    order_id: order.id
  }
});

// In your webhook handler — Metadata comes back in every event type
function handleDelivery(event) {
  const { customer_id, order_id } = event.Metadata || {};
  if (order_id) {
    // db.orders.update({ id: order_id }, { emailDelivered: true });
  }
}
```
references/payload-examples.md Reference
# Webhook Payload Examples

All webhook payloads are delivered as HTTP POST with `Content-Type: application/json`. Use `RecordType` to identify the event.

## Delivery

Fired when the recipient's mail server accepts the message.

```json
{
  "RecordType": "Delivery",
  "ServerID": 23,
  "MessageStream": "outbound",
  "MessageID": "883953f4-6105-42a2-a16a-77a8eac79483",
  "Recipient": "[email protected]",
  "Tag": "welcome-email",
  "DeliveredAt": "2025-04-05T16:33:54.9070259Z",
  "Details": "Test delivery webhook details",
  "Metadata": {
    "customer_id": "12345"
  }
}
```

`DeliveredAt` is when the recipient's server accepted the message — not when it was read.

---

## Bounce

Fired when an email is rejected by the recipient's server.

```json
{
  "RecordType": "Bounce",
  "ID": 42,
  "Type": "HardBounce",
  "TypeCode": 1,
  "Name": "Hard bounce",
  "ServerID": 23,
  "MessageStream": "outbound",
  "MessageID": "883953f4-6105-42a2-a16a-77a8eac79483",
  "Tag": "welcome-email",
  "Description": "The server was unable to deliver your message (ex: unknown user, mailbox not found).",
  "Details": "smtp;550 5.1.1 The email account that you tried to reach does not exist.",
  "Email": "[email protected]",
  "From": "[email protected]",
  "BouncedAt": "2025-04-05T16:33:54.9070259Z",
  "DumpAvailable": true,
  "Inactive": true,
  "CanActivate": true,
  "Subject": "Welcome to our service",
  "Metadata": {
    "customer_id": "12345"
  }
}
```

**Key fields:**
- `Inactive: true` — Postmark has deactivated this address; future sends are blocked
- `CanActivate: true` — Address can be reactivated if the bounce was temporary
- `DumpAvailable: true` — Full SMTP conversation available via the Bounces API

### Bounce Types

| Type | TypeCode | Action |
|------|----------|--------|
| `HardBounce` | 1 | Permanent — remove address from all lists immediately |
| `SoftBounce` | 4096 | Temporary — Postmark retries automatically; monitor |
| `Transient` | 2 | Temporary — retry may succeed |
| `SpamNotification` | 512 | Marked as spam by recipient's server |
| `Blocked` | 16 | Blocked by recipient server |
| `DMARCPolicy` | 100000 | Rejected due to sender's DMARC policy |

---

## Spam Complaint

Fired when a recipient marks your email as spam via a feedback loop.

```json
{
  "RecordType": "SpamComplaint",
  "ID": 42,
  "Type": "SpamComplaint",
  "TypeCode": 512,
  "ServerID": 23,
  "MessageStream": "outbound",
  "MessageID": "883953f4-6105-42a2-a16a-77a8eac79483",
  "Tag": "welcome-email",
  "Email": "[email protected]",
  "From": "[email protected]",
  "BouncedAt": "2025-04-05T16:33:54.9070259Z",
  "Subject": "Welcome to our service",
  "Metadata": {
    "customer_id": "12345"
  }
}
```

**Action:** Immediately and permanently suppress this recipient. Spam complaints severely damage sender reputation — do not retry or re-add to lists.

---

## Open

Fired when a recipient opens the email. Requires `TrackOpens: true` on the sent message.

```json
{
  "RecordType": "Open",
  "FirstOpen": true,
  "ServerID": 23,
  "MessageStream": "outbound",
  "MessageID": "883953f4-6105-42a2-a16a-77a8eac79483",
  "Recipient": "[email protected]",
  "Tag": "welcome-email",
  "ReceivedAt": "2025-04-05T16:33:54.9070259Z",
  "ReadSeconds": 5,
  "Platform": "WebMail",
  "Client": {
    "Name": "Gmail",
    "Company": "Google",
    "Family": "Gmail"
  },
  "OS": {
    "Name": "Windows 10",
    "Company": "Microsoft",
    "Family": "Windows"
  },
  "UserAgent": "Mozilla/5.0 ...",
  "Geo": {
    "CountryISOCode": "US",
    "Country": "United States",
    "RegionISOCode": "CA",
    "Region": "California",
    "City": "San Francisco",
    "Zip": "94107",
    "Coords": "37.7749,-122.4194",
    "IP": "203.0.113.1"
  },
  "Metadata": {
    "customer_id": "12345"
  }
}
```

**Key fields:**
- `FirstOpen: true` — First time this recipient opened this message
- `ReadSeconds` — Estimated read time in seconds
- `Platform` — `Desktop`, `Mobile`, `WebMail`, or `Unknown`

**Note:** Apple Mail Privacy Protection (MPP) inflates open rates. Use click tracking as a more reliable engagement signal.

---

## Click

Fired when a recipient clicks a tracked link. Requires `TrackLinks` to be set on the sent message.

```json
{
  "RecordType": "Click",
  "ClickLocation": "HTML",
  "ServerID": 23,
  "MessageStream": "outbound",
  "MessageID": "883953f4-6105-42a2-a16a-77a8eac79483",
  "Recipient": "[email protected]",
  "Tag": "welcome-email",
  "OriginalLink": "https://yourdomain.com/pricing",
  "ReceivedAt": "2025-04-05T16:33:54.9070259Z",
  "Platform": "Desktop",
  "Client": {
    "Name": "Chrome",
    "Company": "Google",
    "Family": "Chrome"
  },
  "OS": {
    "Name": "macOS 14",
    "Company": "Apple",
    "Family": "macOS"
  },
  "UserAgent": "Mozilla/5.0 ...",
  "Geo": {
    "CountryISOCode": "US",
    "Country": "United States",
    "RegionISOCode": "CA",
    "Region": "California",
    "City": "San Francisco",
    "Zip": "94107",
    "Coords": "37.7749,-122.4194",
    "IP": "203.0.113.1"
  },
  "Metadata": {
    "customer_id": "12345"
  }
}
```

**Key fields:**
- `OriginalLink` — The actual destination URL (before Postmark's tracking redirect)
- `ClickLocation` — `HTML` or `Text`

---

## Subscription Change

Fired when a recipient unsubscribes via the Postmark-managed unsubscribe mechanism (broadcast stream).

```json
{
  "RecordType": "SubscriptionChange",
  "ServerID": 23,
  "MessageStream": "broadcast",
  "MessageID": "883953f4-6105-42a2-a16a-77a8eac79483",
  "Recipient": "[email protected]",
  "Tag": "newsletter",
  "ChangedAt": "2025-04-05T16:33:54.9070259Z",
  "Origin": "Recipient",
  "SuppressSending": true,
  "SuppressionReason": "ManualSuppression",
  "Metadata": {
    "customer_id": "12345"
  }
}
```

**Key fields:**
- `SuppressSending: true` — Postmark has suppressed this address; future sends are blocked
- `Origin` — `Recipient` (self-unsubscribed) or `Customer` (suppressed via API/dashboard)
- `SuppressionReason` — `ManualSuppression`, `HardBounce`, `SpamComplaint`

**Action:** Sync the unsubscribe to your own system immediately. Postmark suppresses automatically, but your database should reflect it too.
references/security.md Reference
# Webhook Security

Verify that webhook requests are genuinely from Postmark before processing them.

## Postmark Does Not Sign Webhooks

There is no HMAC signature, no signing secret, and no signature header on a Postmark webhook request. **Do not write or trust signature-verification logic** — there is nothing to verify against.

Protect your endpoint with these mechanisms instead:

| Mechanism | Covered in |
|-----------|-----------|
| HTTP Basic Auth (credentials in the webhook URL) | Option 1 below |
| IP allowlisting | Option 3 below |
| Payload-shape validation | Option 4 below |

## Option 1: HTTP Basic Authentication (Recommended)

Set credentials when creating the webhook — Postmark includes them in every request via the `Authorization` header.

### Set credentials on the webhook

```javascript
const webhook = await client.createWebhook({
  Url: 'https://yourdomain.com/webhooks/postmark',
  MessageStream: 'outbound',
  HttpAuth: {
    Username: 'postmark-webhook',
    Password: process.env.WEBHOOK_SECRET
  },
  Triggers: { /* ... */ }
});
```

### Validate in your endpoint (Node.js)

```javascript
app.post('/webhooks/postmark', (req, res) => {
  const authHeader = req.headers['authorization'];
  if (!authHeader) return res.sendStatus(401);

  const [scheme, encoded] = authHeader.split(' ');
  if (scheme !== 'Basic') return res.sendStatus(401);

  const decoded = Buffer.from(encoded, 'base64').toString('utf-8');
  const [username, password] = decoded.split(':');

  if (username !== 'postmark-webhook' || password !== process.env.WEBHOOK_SECRET) {
    return res.sendStatus(401);
  }

  res.sendStatus(200);
  // process event...
});
```

### Validate in your endpoint (Python)

```python
import base64, os
from flask import Flask, request

app = Flask(__name__)

@app.route('/webhooks/postmark', methods=['POST'])
def handle_webhook():
    auth = request.headers.get('Authorization', '')
    if not auth.startswith('Basic '):
        return '', 401

    decoded = base64.b64decode(auth[6:]).decode('utf-8')
    username, _, password = decoded.partition(':')

    if username != 'postmark-webhook' or password != os.environ['WEBHOOK_SECRET']:
        return '', 401

    return '', 200
```

---

## Option 2: Custom HTTP Headers (Shared Secret)

```javascript
// Set the header when creating the webhook
const webhook = await client.createWebhook({
  Url: 'https://yourdomain.com/webhooks/postmark',
  HttpHeaders: [
    { Name: 'X-Webhook-Secret', Value: process.env.WEBHOOK_SECRET }
  ],
  Triggers: { /* ... */ }
});

// Validate in your endpoint
app.post('/webhooks/postmark', (req, res) => {
  const secret = req.headers['x-webhook-secret'];
  if (!secret || secret !== process.env.WEBHOOK_SECRET) {
    return res.sendStatus(401);
  }
  res.sendStatus(200);
});
```

---

## Option 3: IP Allowlisting

Restrict your endpoint to Postmark's IP ranges at the network level (firewall, load balancer ACLs). Check [Postmark's documentation](https://postmarkapp.com/developer/webhooks/webhooks-overview) for the current IP list — it can change, so don't hardcode it.

---

## Option 4: Payload-Shape Validation

Reject anything that does not look like a Postmark event before you act on it. This costs nothing and catches malformed or spoofed bodies that pass transport-level checks.

```javascript
const VALID_RECORD_TYPES = new Set([
  'Delivery', 'Bounce', 'SpamComplaint', 'Open', 'Click', 'SubscriptionChange'
]);

function isValidPostmarkEvent(body) {
  if (!body || typeof body !== 'object') return false;
  if (!VALID_RECORD_TYPES.has(body.RecordType)) return false;
  if (typeof body.MessageID !== 'string') return false;
  return true;
}

app.post('/webhooks/postmark', (req, res) => {
  if (!isValidPostmarkEvent(req.body)) return res.sendStatus(400);

  res.sendStatus(200);
  // process event...
});
```

```python
VALID_RECORD_TYPES = {
    'Delivery', 'Bounce', 'SpamComplaint', 'Open', 'Click', 'SubscriptionChange'
}

def is_valid_postmark_event(body):
    if not isinstance(body, dict):
        return False
    if body.get('RecordType') not in VALID_RECORD_TYPES:
        return False
    if not isinstance(body.get('MessageID'), str):
        return False
    return True
```

---

## Security Best Practices

| Practice | Why |
|----------|-----|
| Always use HTTPS | Prevents credentials being intercepted in transit |
| Don't rely on signature verification | Postmark does not sign webhooks — use Basic Auth, IP allowlisting, and payload-shape validation |
| Return 401 for auth failures | 401 is the semantically correct response. Note that for outbound webhooks every 4xx except 408 and 429 is permanent — a 401 or 403 drops the event on the first attempt with no retries |
| Fix auth misconfiguration immediately | Because auth failures are never retried, a bad credential silently loses events. Watch `FailureCount` and `SuccessRate` via `GET /webhooks/{Id}/statistics` |
| Use constant-time comparison | Prevents timing attacks |
| Rotate secrets periodically | Limits exposure if a secret is compromised |

### Constant-time comparison (Node.js)

```javascript
const crypto = require('crypto');

function safeCompare(a, b) {
  const bufA = Buffer.from(String(a));
  const bufB = Buffer.from(String(b));
  if (bufA.length !== bufB.length) return false;
  return crypto.timingSafeEqual(bufA, bufB);
}

app.post('/webhooks/postmark', (req, res) => {
  const incoming = req.headers['x-webhook-secret'] || '';
  if (!safeCompare(incoming, process.env.WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }
  res.sendStatus(200);
});
```
references/webhook-setup.md Reference
# Webhook Setup and Management

## Create a Webhook

**Endpoint:** `POST /webhooks`

### Node.js

```javascript
const postmark = require('postmark');
const client = new postmark.ServerClient(process.env.POSTMARK_SERVER_TOKEN);

const webhook = await client.createWebhook({
  Url: 'https://yourdomain.com/webhooks/postmark',
  MessageStream: 'outbound',
  Verify: true,
  HttpAuth: {
    Username: 'webhook-user',
    Password: process.env.WEBHOOK_SECRET
  },
  HttpHeaders: [
    { Name: 'X-Custom-Header', Value: 'my-value' }
  ],
  Triggers: {
    Delivery: { Enabled: true },
    Bounce: { Enabled: true, IncludeContent: false },
    SpamComplaint: { Enabled: true, IncludeContent: false },
    Open: { Enabled: true, PostFirstOpenOnly: true },
    Click: { Enabled: true },
    SubscriptionChange: { Enabled: true }
  }
});

console.log('Webhook ID:', webhook.ID);
```

### cURL

```bash
curl "https://api.postmarkapp.com/webhooks" \
  -X POST \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -H "X-Postmark-Server-Token: $POSTMARK_SERVER_TOKEN" \
  -d '{
    "Url": "https://yourdomain.com/webhooks/postmark",
    "MessageStream": "outbound",
    "Verify": true,
    "Triggers": {
      "Delivery": { "Enabled": true },
      "Bounce": { "Enabled": true, "IncludeContent": false },
      "SpamComplaint": { "Enabled": true, "IncludeContent": false },
      "Open": { "Enabled": true, "PostFirstOpenOnly": true },
      "Click": { "Enabled": true },
      "SubscriptionChange": { "Enabled": true }
    }
  }'
```

**Webhooks are per-stream.** Create separate webhooks for `outbound` and `broadcast` streams if you need events from both.

---

## List Webhooks

```javascript
const webhooks = await client.getWebhooks({ MessageStream: 'outbound' });

webhooks.Webhooks.forEach(w => {
  console.log(`${w.ID}: ${w.Url} (stream: ${w.MessageStream})`);
});
```

```bash
curl "https://api.postmarkapp.com/webhooks?MessageStream=outbound" \
  -H "Accept: application/json" \
  -H "X-Postmark-Server-Token: $POSTMARK_SERVER_TOKEN"
```

---

## Update a Webhook

Editing a webhook re-runs verification for every enabled event type. If any trigger fails, the edit is rejected with HTTP 422 and error code 1364, and **the existing webhook is left unchanged**.

```javascript
await client.editWebhook(webhookId, {
  Url: 'https://yourdomain.com/webhooks/postmark-v2',
  Verify: true, // default — re-test every enabled trigger before saving the edit
  Triggers: {
    Open: { Enabled: true, PostFirstOpenOnly: true },
    Click: { Enabled: true },
    Delivery: { Enabled: true },
    Bounce: { Enabled: true, IncludeContent: false },
    SpamComplaint: { Enabled: true },
    SubscriptionChange: { Enabled: true }
  }
});
```

---

## Delete a Webhook

```javascript
await client.deleteWebhook(webhookId);
```

---

## Endpoint Verification

When a webhook is created or edited, Postmark tests the endpoint for **every enabled event type**. Each test must return HTTP 200 before the webhook is saved as verified.

### The `Verify` field

| Field | Type | Default | Behavior |
|-------|------|---------|----------|
| `Verify` | boolean | `true` | `true` — test every enabled trigger before saving. `false` — save the webhook **without** testing; it stays unverified and receives no events until it passes verification later. |

Failed verification on create or edit is rejected with **HTTP 422** and **error code 1364**. Nothing is saved on create; on edit the existing webhook is left unchanged.

```javascript
try {
  await client.createWebhook({
    Url: 'https://yourdomain.com/webhooks/postmark',
    MessageStream: 'outbound',
    Verify: true,
    Triggers: { Delivery: { Enabled: true }, Bounce: { Enabled: true } }
  });
} catch (error) {
  if (error.statusCode === 422 && error.code === 1364) {
    // Verification failed — nothing was saved. Fix the endpoint and retry.
    console.error('Endpoint verification failed:', error.message);
  }
}
```

To save a webhook before its endpoint is live, set `Verify: false` and verify later:

```javascript
await client.createWebhook({
  Url: 'https://yourdomain.com/webhooks/postmark',
  MessageStream: 'outbound',
  Verify: false, // saved unverified — receives no events until it passes verification
  Triggers: { Delivery: { Enabled: true }, Bounce: { Enabled: true } }
});
```

---

## Verify on Demand

**Endpoint:** `POST /webhooks/{Id}/verify`

```bash
curl "https://api.postmarkapp.com/webhooks/12345/verify" \
  -X POST \
  -H "Accept: application/json" \
  -H "X-Postmark-Server-Token: $POSTMARK_SERVER_TOKEN"
```

**HTTP 200 means the check RAN — not that it passed.** `Success` can be `false` inside a 200 response. Always read the body.

```json
{
  "Id": 12345,
  "Url": "https://yourdomain.com/webhooks/postmark",
  "Success": false,
  "Results": [
    { "TriggerType": "Delivery", "Success": true, "StatusCode": 200, "Message": "OK" },
    { "TriggerType": "Open", "Success": true, "StatusCode": 200, "Message": "OK" },
    { "TriggerType": "Click", "Success": true, "StatusCode": 200, "Message": "OK" },
    { "TriggerType": "SubscriptionChange", "Success": true, "StatusCode": 200, "Message": "OK" },
    { "TriggerType": "Bounce", "Success": false, "StatusCode": 500, "Message": "Internal Server Error" }
  ],
  "Message": "4/5 triggers verified successfully"
}
```

| Field | Meaning |
|-------|---------|
| `Id` | Webhook ID that was checked |
| `Url` | Endpoint that was checked |
| `Success` | `true` only if **every** enabled trigger returned 200 |
| `Results[]` | Per-trigger outcome — `TriggerType`, `Success`, `StatusCode`, `Message` |
| `Message` | Human-readable summary, e.g. `"4/5 triggers verified successfully"` |

Check the body, not the status code:

```javascript
const result = await verifyWebhook(webhookId); // POST /webhooks/{Id}/verify

// Wrong: a 200 response does not mean the endpoint verified
// if (response.status === 200) console.log('verified');

if (!result.Success) {
  const failed = result.Results.filter(r => !r.Success);
  failed.forEach(r => console.error(`${r.TriggerType}: ${r.StatusCode} ${r.Message}`));
}
```

### Persistent Failure Pauses One Event Type

If an event type keeps failing across many events, Postmark marks **that event type** unverified and pauses delivery for it until the endpoint is fixed and re-verified.

Tracking is **per event type** — Bounce failing does not pause Delivery, Open, Click, SpamComplaint, or SubscriptionChange. Existing webhooks start as verified at launch cutover.

---

## Webhook Statistics

**Endpoint:** `GET /webhooks/{Id}/statistics`

One webhook per call, keyed by ID. There is no all-webhooks or per-stream variant and no status filter. The window is a fixed rolling **24 hours** — not filterable in v1.

```bash
curl "https://api.postmarkapp.com/webhooks/12345/statistics" \
  -H "Accept: application/json" \
  -H "X-Postmark-Server-Token: $POSTMARK_SERVER_TOKEN"
```

```json
{
  "WebhookId": 12345,
  "Url": "https://example.com/webhook",
  "Statuses": { "Bounce": "unverified", "Delivery": "verified" },
  "TimeRange": {
    "StartTime": "2025-04-04T16:33:54.9070259Z",
    "EndTime": "2025-04-05T16:33:54.9070259Z",
    "Hours": 24
  },
  "Metrics": {
    "TotalRequests": 1000,
    "SuccessCount": 950,
    "FailureCount": 50,
    "RetryCount": 62,
    "SuccessRate": 95.0,
    "SlowCount": 18,
    "VerySlowCount": 4,
    "AverageTerminalResponseTimeMs": 240,
    "AverageRetryResponseTimeMs": 1850
  },
  "MetricsByTrigger": {
    "Bounce": {
      "TotalRequests": 100,
      "SuccessCount": 50,
      "FailureCount": 50,
      "RetryCount": 62,
      "SuccessRate": 50.0,
      "SlowCount": 12,
      "VerySlowCount": 4,
      "AverageTerminalResponseTimeMs": 900,
      "AverageRetryResponseTimeMs": 1850
    },
    "Delivery": {
      "TotalRequests": 900,
      "SuccessCount": 900,
      "FailureCount": 0,
      "RetryCount": 0,
      "SuccessRate": 100.0,
      "SlowCount": 6,
      "VerySlowCount": 0,
      "AverageTerminalResponseTimeMs": 165,
      "AverageRetryResponseTimeMs": null
    }
  }
}
```

| Field | Meaning |
|-------|---------|
| `WebhookId` | The webhook these statistics describe |
| `Url` | Endpoint URL |
| `Statuses` | Verification status per event type (`verified` / `unverified`) |
| `TimeRange` | `StartTime`, `EndTime`, `Hours` — always a rolling 24 hours |
| `Metrics` | Top-level aggregate across all event types |
| `MetricsByTrigger` | The same metric fields, broken out per event type |

**Metric fields** — identical in `Metrics` and in each `MetricsByTrigger` entry:

| Field | Meaning |
|-------|---------|
| `TotalRequests` | Requests attempted in the window |
| `SuccessCount` | Requests that succeeded |
| `FailureCount` | Requests that failed |
| `RetryCount` | Retry attempts made |
| `SuccessRate` | Percentage of requests that succeeded |
| `SlowCount` | Slow responses |
| `VerySlowCount` | Very slow responses |
| `AverageTerminalResponseTimeMs` | Average response time of terminal attempts |
| `AverageRetryResponseTimeMs` | Average response time of retry attempts — `null` when the trigger had no retries |

Use `Statuses` to spot a paused event type, and per-trigger `SuccessRate` to find which endpoint path is failing:

```javascript
const stats = await getWebhookStatistics(webhookId); // GET /webhooks/{Id}/statistics

Object.entries(stats.Statuses).forEach(([trigger, status]) => {
  if (status === 'unverified') {
    console.warn(`${trigger} is paused — fix the endpoint and re-verify`);
  }
});

Object.entries(stats.MetricsByTrigger).forEach(([trigger, m]) => {
  console.log(`${trigger}: ${m.SuccessRate}% success, ${m.RetryCount} retries`);
  // AverageRetryResponseTimeMs is null when there were no retries
  if (m.AverageRetryResponseTimeMs !== null) {
    console.log(`  avg retry response: ${m.AverageRetryResponseTimeMs}ms`);
  }
});
```

---

## Retry Schedule (Outbound Webhooks)

This schedule applies to **outbound** webhooks — delivery, bounce, open, click, spam complaint, and subscription change. Inbound email webhooks run on separate infrastructure with a different, longer schedule; see the `postmark-inbound` skill. Do not apply one to the other.

### Retryable vs Permanent Failures

Postmark retries only **temporary** failures:

| Response | Classification | Behavior |
|----------|---------------|----------|
| `5xx` | Temporary | Retried |
| `408` Request Timeout | Temporary | Retried |
| `429` Too Many Requests | Temporary | Retried |
| Network timeout | Temporary | Retried |
| Every other `4xx` — including `400`, `401`, `403`, `404`, `405`, `410`, `422` | **Permanent** | Dropped on the first attempt — no retries |
| `3xx` | Temporary | Redirects are followed by default (up to 10 hops), so Postmark sees the **final** status and classifies that. A bare `3xx` is treated as temporary and retried — it is not a permanent failure. Don't rely on this: return `200` from the endpoint Postmark posts to. |

### Schedule

When a failure is retryable, a webhook gets **up to 9 retries over approximately 72 minutes**. The intervals are not a single flat list — delivery runs in tiers, and a webhook that starts failing works through two rungs in order:

**Rung 1 — standard (3 retries, 21 minutes)**

| Retry | Interval After Previous | Cumulative |
|-------|------------------------|------------|
| 1 | 1 minute | 1 min |
| 2 | 5 minutes | 6 min |
| 3 | 15 minutes | 21 min |

**Rung 2 — backoff (6 retries, 51 minutes)** — entered after rung 1 is exhausted:

| Retry | Interval After Previous | Cumulative |
|-------|------------------------|------------|
| 4 | 1 minute | 22 min |
| 5 | 5 minutes | 27 min |
| 6 | 10 minutes | 37 min |
| 7 | 10 minutes | 47 min |
| 8 | 10 minutes | 57 min |
| 9 | 15 minutes | 72 min |

After retry 9 the event is dropped permanently.

Note that the gap does **not** widen monotonically — it resets to 1 minute at the start of rung 2. If you are reconciling delivery gaps against these numbers, expect the 1m/5m pattern to appear twice.

The **same 9-retry ladder applies to every outbound event type** — delivery, bounce, open, click, spam complaint, and subscription change all retry identically, and the schedule cannot be customized per webhook or per event type.

### Reading `X-PM-Retries-Remaining`

Every delivery attempt carries an **`X-PM-Retries-Remaining`** header. It starts at **9** on the first failure and counts down to `0` on the final attempt, spanning both rungs — so a `9` early in an incident is expected, not a sign of misconfiguration.

Always return 200 and process asynchronously. Because a retry can redeliver an event your endpoint already processed, handlers must be idempotent — see [handler-examples.md](handler-examples.md).

Version History

v1.1.0 Synced from GitHub
1 month ago
v1.0.0 Imported from GitHub
7 months ago