Postmark API notes
What the Postmark adapter does under the hood — the endpoints, the limits, the quirks worth knowing, and the capabilities it declares.
Capabilities
| Capability | Value | Consequence |
|---|---|---|
one_off_send | true | Can be the one-off sender. |
broadcast_send | false | No bulk-campaign API, so broadcasts fan out through the paced queue — with a full report. |
custom_headers | true | Our headers are passed through, converted to Postmark's header shape. |
injects_unsubscribe_headers | false | The RFC 8058 headers are ours — provided the stream's unsubscribe handling is set to custom (see quirks). |
send_time_suppression | true | Postmark drops sends to addresses suppressed on the stream. |
suppression_read | :per_address | The suppression dump filters by address, so the periodic suppression checks run. |
suppression_webhooks | true | Bounces, complaints and subscription changes arrive by webhook. |
engagement_webhooks | false | TrackOpens/TrackLinks are forced off per message — tracking is ours. |
push_unsubscribe | true | A local unsubscribe is mirrored to Postmark as a manual suppression. |
remote_delete | false | GDPR erase can't remove the address Postmark-side. |
Sending
| Case | Endpoint |
|---|---|
| One message | POST /email |
| More than one | POST /email/batch, chunked at 500 |
Postmark publishes no per-minute send cap, so the adapter declares
emails_per_minute: nil and the sender runs unpaced. If a real
account ever meets Postmark's rate limiter, setting a number there is the whole
fix.
The batch endpoint answers 200 either way
/email/batch returns HTTP 200 even when individual messages fail —
the truth is the per-item ErrorCode in the response array. The
adapter inspects every item: a wholly rejected batch is a failure naming the
error codes, and a partly rejected one succeeds with a
failed_count. A naive port from another provider would report a
fully rejected batch as a success.
Message streams
Every payload carries an explicit MessageStream: the configured
broadcast stream for marketing mail, outbound for transactional.
It also carries TrackOpens: false and
TrackLinks: "None" on every message, so a server-level tracking
default flipped on in Postmark's dashboard can't quietly double-track.
Headers
"Headers": [
{ "Name": "List-Unsubscribe", "Value": "<https://your-instance.com/u/SIGNED_TOKEN>" },
{ "Name": "List-Unsubscribe-Post", "Value": "List-Unsubscribe=One-Click" }
]
Postmark takes headers as an array of name/value objects rather than a hash — the adapter converts. The token is the same signed token the subscription page takes, which is what attributes a header unsubscribe to the exact email it came from.
Transactional mail carries no unsubscribe header. You can't opt out of your own password reset.
Webhooks
| Postmark event | Normalizes to |
|---|---|
Delivery | delivered |
Bounce | bounced, hard or soft |
SpamComplaint | complained |
SubscriptionChange, suppressing, by the recipient | unsubscribed |
SubscriptionChange, suppressing, any other way | suppressed |
SubscriptionChange, reactivating | acknowledged and ignored — the local database is the resubscribe authority |
Open, Click, anything else | acknowledged and ignored |
A bounce maps to our bounce kind from Postmark's own judgment: hard when
Inactive is true (Postmark deactivated the address) or the
Type is one of HardBounce,
BadEmailAddress, SpamNotification,
ManuallyDeactivated; soft otherwise — a full mailbox is a
try-again, not a dead address, and only hard bounces suppress.
Manual subscription changes carry the zero GUID
(00000000-0000-0000-0000-000000000000) as their
MessageID; the adapter normalizes it to nothing so it can never
attribute to a stored send. The person is found by address instead.
Verification: Postmark documents that it can't sign webhooks, so the claim is
the secret in the URL — ?secret=…, compared in constant time
against provider.postmark.webhook_secret. See
Webhooks for the shared contract.
Suppression
| Operation | Endpoint |
|---|---|
| Read one address | GET /message-streams/{stream}/suppressions/dump?EmailAddress=… |
| Push an unsubscribe | POST /message-streams/{stream}/suppressions |
| Lift a suppression | POST /message-streams/{stream}/suppressions/delete |
Suppression is per message stream on Postmark's side; the adapter reads and
writes the broadcast stream, where marketing suppression lives. Transactional
(outbound-stream) bounces still arrive by webhook. Write calls take up to 50
addresses and answer with a per-item Status inside a 200 — the
adapter checks it. Postmark refuses to delete spam-complaint suppressions by
design; that surfaces as a failure, and the address stays unmailed on both
sides.
Quirks
- The batch endpoint's 200 means "received", not "accepted". Per-item
ErrorCodeis the truth. The adapter handles it. - Custom unsubscribe handling is permission-gated. Until Postmark support enables it and the broadcast stream is set to "Manage unsubscribes on your own", Postmark replaces our RFC 8058 headers with its own and appends its own footer unsubscribe link to broadcast-stream mail. Those unsubscribes still sync back via the SubscriptionChange webhook. Verify connection warns about it.
- The messages and suppression APIs are eventually consistent. A just-sent message or just-pushed suppression can take seconds to appear in reads. Retry before concluding it's missing.
- New accounts are restricted to delivering to addresses on the sender signature's own domain until Postmark approves the account.
Credentials
| Setting | What |
|---|---|
provider.postmark.server_token | The Server API token. Sending, suppression, verification. |
provider.postmark.account_token | Optional Account API token — only Verify connection's sender-signature check uses it. |
provider.postmark.webhook_secret | The URL secret webhooks are checked against. |
provider.postmark.broadcast_stream | The Broadcasts-type stream ID. Configuration rather than a secret; defaults to broadcasts. |
All are entered on the Settings page; the secrets are encrypted in the instance database, and none ever appears in a definitions repo.
verify_config calls GET /server to validate the token,
GET /message-streams/{stream} and fails unless the
configured stream exists and is Broadcasts-type, and — when the account token is
present — GET /senders, failing when no sender
signature is confirmed, naming the ones still pending. It also returns warnings
for server-level tracking left on and for Postmark-managed unsubscribe handling.
See also: Postmark setup · Webhooks · Write your own adapter