# Postmark API notes

How the Postmark adapter works: endpoints, message streams, batch semantics, webhook normalization, suppression API, and declared capabilities.

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 |
| --- | --- | --- |
| `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`. Pacing comes from the account-wide ceiling instead — `sending.max_emails_per_minute`, 300 by default, set on Settings → Sending. The effective pace is always the lower of the adapter's declaration and the account ceiling, so if a real account ever meets Postmark's rate limiter, declaring a number here is still 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-mimeo.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](https://mimeohq.com/docs/api/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 `ErrorCode` is 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 `broadcast`, the ID of the stream Postmark auto-creates. |

All are entered on the Settings page; the secrets are encrypted in your Mimeo's database, and none ever appears in a [manager repo](https://mimeohq.com/docs/reference/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](https://mimeohq.com/docs/providers/postmark) · [Webhooks](https://mimeohq.com/docs/api/webhooks) · [Write your own adapter](https://mimeohq.com/docs/reference/write-your-own-adapter)
