postmark-webhooks
Public
0
Repository: activecampaign/postmark-skills
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
# 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
# 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);
});
```
## 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 });
}
}
```
# 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.
## 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);
});
```
# 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