postmark-webhooks

Public
0

Repository: activecampaign/postmark-skills

Log in or sign up to clone this skill.

A
activecampaign
Imported Mar 3, 2026

Low Risk

No security issues found

INFO

Skill manifest does not include a 'license' field. Specifying a license helps users understand usage terms.

Remediation Add 'license' field to SKILL.md frontmatter (e.g., MIT, Apache-2.0)

Scanned in 0.008s

Description

Use when setting up Postmark webhooks for tracking email delivery, bounces, opens, clicks, spam complaints, or subscription changes — includes webhook configuration, payload handling, and security.

Details

License MIT
Metadata
author
postmark
version
1.0.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.

| 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. **Handle payloads** — parse the JSON body for each event type
5. **Respond with 200** — acknowledge receipt immediately

## 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 |

### 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',
  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",
    "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` |

## 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.

## Security

Always verify that requests are genuinely from Postmark using HTTP Basic Auth, custom headers, or IP allowlisting.

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, 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) |
| 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 |
| Duplicate handling | Webhooks may be delivered more than once — use `MessageID` for deduplication |
| 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 retries failed webhook deliveries up to **10 times** over ~10.5 hours with escalating intervals: 1 min, 5 min, 10 min, 10 min, 10 min, 15 min, 30 min, 1 hr, 2 hrs, 6 hrs. A **403 response** immediately stops all retries. This retry schedule cannot be customized
- 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);
});
```

## Deduplication

Postmark may deliver webhooks more than once. Use `MessageID` + `RecordType` to deduplicate:

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

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

  const event = req.body;
  const key = `${event.RecordType}-${event.MessageID}`;

  if (processedEvents.has(key)) return;
  processedEvents.add(key);

  processEvent(event);
});
```

## 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.

## 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.

---

## Security Best Practices

| Practice | Why |
|----------|-----|
| Always use HTTPS | Prevents credentials being intercepted in transit |
| Return 401 for auth failures, not 403 | A 403 stops all Postmark retries permanently |
| 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',
  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",
    "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

```javascript
await client.editWebhook(webhookId, {
  Url: 'https://yourdomain.com/webhooks/postmark-v2',
  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);
```

---

## Retry Schedule

Postmark retries webhook delivery when your endpoint does not return HTTP 200:

| Retry | Interval After Previous |
|-------|------------------------|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 10 minutes |
| 4 | 10 minutes |
| 5 | 10 minutes |
| 6 | 15 minutes |
| 7 | 30 minutes |
| 8 | 1 hour |
| 9 | 2 hours |
| 10 | 6 hours |

A **403 response** immediately stops all retries. Always return 200 and process asynchronously.

Version History

v1.0.0 Imported from GitHub
1 week ago