postmark-webhooks
PublicRepository: activecampaign/postmark-skills
Low Risk
No security issues found
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)
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
- author
- postmark
- version
- 1.0.0
Skill Files
# 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
# 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.
# 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 });
}
}
```
# 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.
# 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);
});
```
# 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.