Theme

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

CapabilityValueConsequence
one_off_sendtrueCan be the one-off sender.
broadcast_sendfalseNo bulk-campaign API, so broadcasts fan out through the paced queue — with a full report.
custom_headerstrueOur headers are passed through, converted to Postmark's header shape.
injects_unsubscribe_headersfalseThe RFC 8058 headers are ours — provided the stream's unsubscribe handling is set to custom (see quirks).
send_time_suppressiontruePostmark drops sends to addresses suppressed on the stream.
suppression_read:per_addressThe suppression dump filters by address, so the periodic suppression checks run.
suppression_webhookstrueBounces, complaints and subscription changes arrive by webhook.
engagement_webhooksfalseTrackOpens/TrackLinks are forced off per message — tracking is ours.
push_unsubscribetrueA local unsubscribe is mirrored to Postmark as a manual suppression.
remote_deletefalseGDPR erase can't remove the address Postmark-side.

Sending

CaseEndpoint
One messagePOST /email
More than onePOST /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 eventNormalizes to
Deliverydelivered
Bouncebounced, hard or soft
SpamComplaintcomplained
SubscriptionChange, suppressing, by the recipientunsubscribed
SubscriptionChange, suppressing, any other waysuppressed
SubscriptionChange, reactivatingacknowledged and ignored — the local database is the resubscribe authority
Open, Click, anything elseacknowledged 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

OperationEndpoint
Read one addressGET /message-streams/{stream}/suppressions/dump?EmailAddress=…
Push an unsubscribePOST /message-streams/{stream}/suppressions
Lift a suppressionPOST /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

Credentials

SettingWhat
provider.postmark.server_tokenThe Server API token. Sending, suppression, verification.
provider.postmark.account_tokenOptional Account API token — only Verify connection's sender-signature check uses it.
provider.postmark.webhook_secretThe URL secret webhooks are checked against.
provider.postmark.broadcast_streamThe 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