Postmark Integration
Receive and route Postmark webhooks for email delivery events, bounces, spam complaints, opens, and clicks.
Info
Postmark does not sign webhooks. Instead, Hookbase generates a secret for the source and you add
it to the Postmark webhook as a custom header named Authorization. Postmark sends that header
verbatim on every request, and Hookbase compares it with the source's secret.
Setup
1. Create a Source in Hookbase
Create the source with provider: "postmark" and leave signingSecret out — Hookbase generates
one and returns it in the create response (this is the only time the full value is returned; you
can also reveal it later from the source's settings). You can supply your own long random value
instead.
curl -X POST https://api.hookbase.app/api/sources \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Postmark Production",
"slug": "postmark",
"provider": "postmark"
}'Save your webhook URL:
https://api.hookbase.app/ingest/{orgSlug}/postmark2. Configure Postmark Webhooks
- Go to Postmark and sign in
- Navigate to Servers → select your server → select the message stream → Webhooks
- Click Add webhook
- Enter your Hookbase webhook URL
- Under custom headers, add a header named
Authorizationwhose value is your Hookbase source's secret — without it every event fails verification withmissing_signature_header - Select the event types you want to receive (Delivery, Bounce, Spam Complaint, Open, Click, Subscription Change)
- Click Save webhook
Warning
Do not also fill in Postmark's basic HTTP auth credentials on the same webhook. Postmark sends
those as its own Authorization: Basic ... header, which collides with the custom Authorization
header Hookbase checks.
Tip
Postmark allows you to configure separate webhook URLs for different event types. You can create multiple Hookbase sources (e.g., postmark-bounces, postmark-deliveries) to route different event types independently.
3. Create Routes
# Create destination for your email event handler
curl -X POST https://api.hookbase.app/api/destinations \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{"name": "Email Service", "slug": "email-service", "url": "https://api.myapp.com/webhooks/postmark"}'
# Create route
curl -X POST https://api.hookbase.app/api/routes \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{"name": "Postmark to Email Service", "sourceId": "src_...", "destinationId": "dst_..."}'Signature Verification
Postmark does not sign webhooks — per its own docs it "does not currently support HMAC webhook
signature verification". Hookbase's postmark provider therefore checks a shared secret instead:
the value of the Authorization custom header you added in step 2 must equal the source's
signingSecret exactly (compared in constant time). There is no hash and no timestamp.
{
"provider": "postmark",
"signingSecret": "your-hookbase-generated-secret"
}Once events are arriving with signature_valid: true, set rejectInvalidSignatures: true on the
source to have requests without the right header refused with 401 instead of stored.
IP Allowlisting
As an additional layer, set the source's ipFilterMode to allowlist and fill ipAllowlist with
Postmark's published ranges
— read them from that page rather than copying them from here, since Postmark changes them.
curl -X PATCH https://api.hookbase.app/api/sources/src_... \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"ipFilterMode": "allowlist",
"ipAllowlist": ["<paste Postmark's current ranges here>"]
}'Common Events
Delivery
Triggered when an email is successfully delivered to the recipient's mail server.
{
"RecordType": "Delivery",
"ServerID": 23,
"MessageStream": "outbound",
"MessageID": "883953f4-6105-42a2-a16a-77a8eac79483",
"Recipient": "john@example.com",
"Tag": "welcome-email",
"DeliveredAt": "2026-03-07T16:33:54.9070259Z",
"Details": "Test delivery webhook details",
"Metadata": {
"user_id": "usr_12345",
"campaign": "onboarding"
}
}Bounce
Triggered when an email bounces. Includes the bounce type and description.
{
"RecordType": "Bounce",
"MessageStream": "outbound",
"ID": 4323372036854775807,
"Type": "HardBounce",
"TypeCode": 1,
"Name": "Hard bounce",
"Tag": "welcome-email",
"MessageID": "883953f4-6105-42a2-a16a-77a8eac79483",
"ServerID": 23,
"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": "invalid@example.com",
"From": "sender@myapp.com",
"BouncedAt": "2026-03-07T16:33:54.9070259Z",
"DumpAvailable": true,
"Inactive": true,
"CanActivate": true,
"Subject": "Welcome to MyApp"
}SpamComplaint
Triggered when a recipient marks your email as spam.
{
"RecordType": "SpamComplaint",
"ID": 42,
"Type": "SpamComplaint",
"TypeCode": 512,
"Name": "Spam complaint",
"Tag": "marketing",
"MessageID": "883953f4-6105-42a2-a16a-77a8eac79483",
"ServerID": 23,
"Description": "",
"Email": "complainer@example.com",
"From": "sender@myapp.com",
"BouncedAt": "2026-03-07T14:22:01.0000000Z",
"Subject": "Weekly Newsletter",
"Inactive": true,
"CanActivate": false
}Open
Triggered when a recipient opens an email. Includes geo and client info.
{
"RecordType": "Open",
"FirstOpen": true,
"Client": {
"Name": "Chrome Mobile",
"Company": "Google",
"Family": "Chrome"
},
"OS": {
"Name": "Android",
"Company": "Google",
"Family": "Android"
},
"Platform": "Mobile",
"UserAgent": "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36",
"ReadSeconds": 5,
"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"
},
"MessageID": "883953f4-6105-42a2-a16a-77a8eac79483",
"MessageStream": "outbound",
"ReceivedAt": "2026-03-07T17:01:23.0000000Z",
"Tag": "welcome-email",
"Recipient": "john@example.com",
"Metadata": {
"user_id": "usr_12345"
}
}Click
Triggered when a recipient clicks a link in your email.
{
"RecordType": "Click",
"ClickLocation": "HTML",
"Client": {
"Name": "Chrome",
"Company": "Google",
"Family": "Chrome"
},
"OS": {
"Name": "Windows",
"Company": "Microsoft",
"Family": "Windows"
},
"Platform": "Desktop",
"UserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"OriginalLink": "https://myapp.com/activate?token=abc123",
"Geo": {
"CountryISOCode": "US",
"Country": "United States",
"RegionISOCode": "NY",
"Region": "New York",
"City": "New York",
"Zip": "10001",
"Coords": "40.7128,-74.0060",
"IP": "198.51.100.42"
},
"MessageID": "883953f4-6105-42a2-a16a-77a8eac79483",
"MessageStream": "outbound",
"ReceivedAt": "2026-03-07T17:05:44.0000000Z",
"Tag": "welcome-email",
"Recipient": "john@example.com",
"Metadata": {
"user_id": "usr_12345"
}
}SubscriptionChange
Triggered when a recipient changes their subscription preferences.
{
"RecordType": "SubscriptionChange",
"ChangedAt": "2026-03-07T18:00:00.0000000Z",
"Recipient": "john@example.com",
"Origin": "Recipient",
"SuppressSending": true,
"Tag": "marketing",
"MessageStream": "broadcast",
"ServerID": 23,
"MessageID": "883953f4-6105-42a2-a16a-77a8eac79483",
"Metadata": {
"user_id": "usr_12345"
}
}Postmark Event Types
| Event Type | RecordType | Description |
|---|---|---|
| Delivery | Delivery | Email successfully delivered to recipient's server |
| Bounce | Bounce | Email bounced (hard or soft) |
| Spam Complaint | SpamComplaint | Recipient reported email as spam |
| Open | Open | Recipient opened the email |
| Click | Click | Recipient clicked a link in the email |
| Subscription Change | SubscriptionChange | Recipient changed subscription preferences |
Bounce Types
| Type | TypeCode | Description |
|---|---|---|
HardBounce | 1 | Permanent delivery failure (e.g., invalid address) |
SoftBounce | 4096 | Temporary delivery failure (e.g., mailbox full) |
Transient | 2 | Intermittent issue, may resolve on retry |
Blocked | 16 | Blocked by recipient server |
AutoResponder | 32 | Auto-reply message |
AddressChange | 512 | Address has changed |
DnsError | 8 | DNS resolution failure |
Transform Examples
Slack Alert for Bounces
function transform(payload) {
const isSoftBounce = payload.Type === 'SoftBounce';
const color = isSoftBounce ? 'warning' : 'danger';
const emoji = isSoftBounce ? ':warning:' : ':x:';
return {
blocks: [
{
type: "header",
text: {
type: "plain_text",
text: `${emoji} Email Bounce - ${payload.Type}`
}
},
{
type: "section",
fields: [
{
type: "mrkdwn",
text: `*Recipient:*\n${payload.Email}`
},
{
type: "mrkdwn",
text: `*Bounce Type:*\n${payload.Name}`
},
{
type: "mrkdwn",
text: `*Subject:*\n${payload.Subject || 'N/A'}`
},
{
type: "mrkdwn",
text: `*From:*\n${payload.From}`
}
]
},
{
type: "section",
text: {
type: "mrkdwn",
text: `*Details:*\n${payload.Description}`
}
},
{
type: "context",
elements: [
{
type: "mrkdwn",
text: `Message ID: ${payload.MessageID} | Bounced at: ${payload.BouncedAt}`
}
]
}
]
};
}Database Format
function transform(payload) {
const recordType = payload.RecordType;
const base = {
postmark_message_id: payload.MessageID,
record_type: recordType,
recipient: payload.Recipient || payload.Email,
tag: payload.Tag || null,
message_stream: payload.MessageStream || 'outbound',
server_id: payload.ServerID,
metadata: payload.Metadata || {},
received_at: new Date().toISOString()
};
if (recordType === 'Delivery') {
return {
...base,
delivered_at: payload.DeliveredAt
};
}
if (recordType === 'Bounce') {
return {
...base,
bounce_type: payload.Type,
bounce_type_code: payload.TypeCode,
bounce_description: payload.Description,
bounce_details: payload.Details,
from_address: payload.From,
subject: payload.Subject,
is_inactive: payload.Inactive,
bounced_at: payload.BouncedAt
};
}
if (recordType === 'SpamComplaint') {
return {
...base,
from_address: payload.From,
subject: payload.Subject,
is_inactive: payload.Inactive,
complained_at: payload.BouncedAt
};
}
if (recordType === 'Open') {
return {
...base,
first_open: payload.FirstOpen,
platform: payload.Platform,
os: payload.OS?.Name,
client: payload.Client?.Name,
country: payload.Geo?.Country,
city: payload.Geo?.City,
opened_at: payload.ReceivedAt
};
}
if (recordType === 'Click') {
return {
...base,
original_link: payload.OriginalLink,
click_location: payload.ClickLocation,
platform: payload.Platform,
os: payload.OS?.Name,
client: payload.Client?.Name,
country: payload.Geo?.Country,
city: payload.Geo?.City,
clicked_at: payload.ReceivedAt
};
}
return base;
}Spam Complaint Alert
function transform(payload) {
return {
channel: "#email-alerts",
username: "Postmark Bot",
icon_emoji: ":rotating_light:",
attachments: [{
color: "danger",
title: "Spam Complaint Received",
fields: [
{ title: "Recipient", value: payload.Email, short: true },
{ title: "From", value: payload.From, short: true },
{ title: "Subject", value: payload.Subject || "N/A", short: true },
{ title: "Tag", value: payload.Tag || "N/A", short: true }
],
footer: `Message ID: ${payload.MessageID}`,
ts: Math.floor(Date.now() / 1000)
}]
};
}Filter Examples
Bounces Only
{
"name": "Bounces Only",
"logic": "AND",
"conditions": [
{
"field": "RecordType",
"operator": "equals",
"value": "Bounce"
}
]
}Hard Bounces Only
{
"name": "Hard Bounces",
"logic": "AND",
"conditions": [
{
"field": "RecordType",
"operator": "equals",
"value": "Bounce"
},
{
"field": "Type",
"operator": "equals",
"value": "HardBounce"
}
]
}Delivery and Bounce Events
{
"name": "Delivery Status Events",
"logic": "OR",
"conditions": [
{
"field": "RecordType",
"operator": "equals",
"value": "Delivery"
},
{
"field": "RecordType",
"operator": "equals",
"value": "Bounce"
}
]
}Engagement Events (Opens and Clicks)
{
"name": "Engagement Events",
"logic": "OR",
"conditions": [
{
"field": "RecordType",
"operator": "equals",
"value": "Open"
},
{
"field": "RecordType",
"operator": "equals",
"value": "Click"
}
]
}Specific Tag
{
"name": "Welcome Email Events",
"logic": "AND",
"conditions": [
{
"field": "Tag",
"operator": "equals",
"value": "welcome-email"
}
]
}Headers
Postmark sends these headers with webhook requests:
| Header | Description |
|---|---|
Content-Type | Always application/json |
User-Agent | Postmark user agent string |
X-PM-Webhook-EventType | The event type (e.g., Bounce, Delivery) |
X-PM-Webhook-TraceID | Unique ID for the webhook delivery |
Authorization | Custom header you configure — Hookbase compares it with the source's secret |
Hookbase does not forward these to your destination. A delivery is a fresh request with
Hookbase's own X-Delivery-ID and X-Event-ID plus the headers and auth you configured on the
destination — nothing Postmark sent reaches your handler as a header. See
what headers a delivery carries.
Testing
Using Postmark's Test Webhook
Postmark provides a built-in webhook test tool:
- Go to Servers → your server → your message stream → Webhooks
- Click on your webhook
- Use the Send test button to send a sample payload for each event type
Manual Test
Send a test Postmark-style payload to your Hookbase source:
curl -X POST https://api.hookbase.app/ingest/{orgSlug}/postmark \
-H "Content-Type: application/json" \
-H "Authorization: your-hookbase-generated-secret" \
-d '{
"RecordType": "Delivery",
"ServerID": 23,
"MessageStream": "outbound",
"MessageID": "test-message-id",
"Recipient": "test@example.com",
"Tag": "test",
"DeliveredAt": "2026-03-07T16:33:54Z",
"Details": "Test delivery",
"Metadata": {}
}'Best Practices
-
Secure your webhooks: Postmark does not sign webhooks, so always add the
Authorizationcustom header and turn onrejectInvalidSignatures; restricting the source to Postmark’s IP ranges adds a second layer -
Handle all bounce types: Distinguish between hard and soft bounces. Hard bounces should suppress future sends; soft bounces may be temporary
-
Act on spam complaints immediately: Remove complainers from your mailing lists to protect your sender reputation
-
Use separate sources per stream: If you use multiple Postmark message streams (transactional, broadcast), create separate Hookbase sources for each
-
Track engagement metrics: Use Open and Click events to build engagement profiles, but remember that open tracking is not 100% accurate due to email client privacy features
-
Store MessageID: Log Postmark's
MessageIDfor cross-referencing events with your sent email records -
Monitor inactive recipients: When
Inactiveistrueon a bounce or complaint, Postmark has suppressed sending to that address
Troubleshooting
Webhook Not Triggering
- Verify the webhook URL is correct in Servers → your server → your message stream → Webhooks
- Ensure the correct event types are checked (Delivery, Bounce, etc.)
- Check that your server is actively sending emails that would trigger events
- Use Postmark's Send test button to verify connectivity
Authentication Failing
- Confirm the source's
providerispostmark missing_signature_header: the webhook has no custom header namedAuthorization— add it in Postmarkmismatch: the header value differs from the source's secret — check for a stray space, or that you rotated the secret in Hookbase without updating Postmark- If basic HTTP auth credentials are set on the Postmark webhook, remove them — they replace the custom
Authorizationheader - Check the source’s
ipAllowlistagainst Postmark’s current published ranges — a stale entry rejects everything
Missing Events
- Confirm the specific event type is enabled on your Postmark webhook
- Postmark requires separate webhook configurations per event type in some setups
- Check Postmark's Activity tab for delivery logs and event history
- Verify filters in Hookbase are not excluding expected events
Bounce Events Not Appearing
- Bounces only fire for actual delivery failures -- test with a known-invalid address
- Check if the email was suppressed before sending (Postmark skips sending to suppressed addresses)
- Review the Bounces tab in Postmark's dashboard for bounce records
Duplicate Events
- Postmark may retry webhook deliveries if it does not receive a 2xx response
- Use the
MessageIDfield combined withRecordTypeas an idempotency key in your handler - Enable deduplication on your Hookbase source. Hookbase has
no provider event-id extractor for Postmark, so use the
auto(orpayload_hash) strategy, which hashes the payload —provider_idwould find no id and skip dedup entirely:
curl -X PATCH .../sources/11111111-1111-4111-8111-111111110022 \
-d '{"dedupEnabled": true, "dedupStrategy": "auto"}'