# Migrate from Postmark

This page maps Postmark's send payload, suppression dumps, and webhooks to Bird. Follow the [main migration guide](/docs/guides/email/migrate) in order, and use these mappings for steps 1, 3, and 4.

Postmark's documentation states that it "does not currently support HMAC webhook signature verification" (read September 2026), so step 4 adds verification your handler does not have today. Read [Translate webhook events](#translate-webhook-events) before you plan the cutover.

## Hand this to your agent

Paste this into Claude Code, Cursor, or Codex. The agent works through this page against your own repository, using whichever Bird surface it already has: the MCP server if one is connected, the CLI if it is installed and signed in.

```text
I am moving an email integration from Postmark to Bird. Route through it with me.
1. Check what you already have before setting anything up. If Bird's MCP server is connected, use its tools. If the Bird CLI is installed and signed in, use that. Either one is enough, and every step below is an action you take with whichever you have. Only if neither is present, follow https://bird.com/docs/ai/set-up-your-agent.md to set one up and sign me in. Every Bird docs page serves Markdown at its own URL with `.md` appended, so fetch that rather than the HTML.
2. Read https://bird.com/docs/guides/email/migrate/postmark.md for the payload, suppression and webhook mapping, and https://bird.com/docs/guides/email/migrate.md for the order the steps go in.
3. Find and list my Postmark usage in this repository before you change anything: calls to /email and /email/withTemplate and any SDK wrappers around them, every MessageStream name I send on, the webhook handler and the URL it is registered at, and every domain I send from. Tell me the list before you edit anything.
4. Register each of those sending domains with Bird and give me the DNS records to publish, following https://bird.com/docs/guides/email/sending-domains.md. Leave every DNS record Postmark uses exactly as it is: Bird's records are published alongside them and both providers authenticate side by side until I switch traffic. Publishing DNS affects mail for the whole domain, so show me the records and let me publish them.
5. Export my suppressions and import them into Bird before any production traffic goes through Bird, so my first sends do not reach addresses that already bounced or complained. Postmark keeps suppressions per message stream, so dump GET /message-streams/{stream_id}/suppressions/dump once for every stream I send on rather than only the default outbound stream. The Bird import takes one address per request and is idempotent, so a partial re-run is safe. https://bird.com/docs/guides/email/suppressions.md has the reason taxonomy.
6. Port the send call and the webhook handler using the mapping tables on the provider page. Postmark's docs say it does not currently support HMAC webhook signature verification, so my handler probably has no signature check and adding one is new code rather than a swap. If my registered Postmark webhook URL carries HTTP Basic credentials, take them out and tell me to rotate that pair rather than reusing it on the Bird endpoint: a URL-embedded credential should be treated as exposed. https://bird.com/docs/guides/webhooks.md and https://bird.com/docs/guides/email/events.md.
7. Run my whole integration against Bird's mail sandbox before any production traffic, following https://bird.com/docs/guides/email/testing-sandbox.md. Sandbox sends run the real pipeline without reaching an inbox or touching my sending reputation.
8. Stop and ask me wherever a step needs a decision. Do not point production traffic at Bird, and do not retire the Postmark path, until I have seen the sandbox results and told you to go ahead. Finish by telling me what is left that only a person can do.
```

## Map the send call

Postmark splits sending across two endpoints: `POST /email` for a composed message and `POST /email/withTemplate` for a stored template. Our [`POST /v1/email/messages`](/docs/api/reference/create-email-message) is one endpoint for both, with the template referenced in a field.

| What it does       | Postmark                                                  | Bird                                                 |
| ------------------ | --------------------------------------------------------- | ---------------------------------------------------- |
| Auth               | `X-Postmark-Server-Token` header                          | `Authorization: Bearer`                              |
| Sender             | `From`                                                    | `from`                                               |
| Recipients         | `To` / `Cc` / `Bcc` (comma-separated, max 50)             | `to` / `cc` / `bcc` (arrays)                         |
| Subject            | `Subject`                                                 | `subject`                                            |
| Body               | `HtmlBody` / `TextBody`                                   | `html` / `text` (at least one)                       |
| Reply-to           | `ReplyTo` (comma-separated)                               | `reply_to` (array)                                   |
| Custom headers     | `Headers` (`Name`/`Value` objects)                        | `headers` (string → string object)                   |
| Filterable label   | `Tag` (one per message)                                   | `tags`: `{name, value}` pairs                        |
| Round-trip context | `Metadata`                                                | `metadata`: arbitrary JSON                           |
| Stored template    | `TemplateId` / `TemplateAlias` + `TemplateModel`          | `template` + `template.parameters`                   |
| Open tracking      | `TrackOpens`                                              | `track_opens` (default `true`)                       |
| Click tracking     | `TrackLinks` (`None`/`HtmlAndText`/`HtmlOnly`/`TextOnly`) | `track_clicks` (boolean, see below)                  |
| Attachments        | `Attachments` (`Name`, `Content`, `ContentType`)          | `attachments`                                        |
| Traffic separation | `MessageStream`                                           | (no counterpart, see below)                          |
| Category           | (none)                                                    | `category`: `marketing` (default) or `transactional` |
| Scheduling         | (none)                                                    | `scheduled_at`                                       |

Our field caps and defaults (recipient counts, tag and metadata limits) live in [Sending email](/docs/guides/email/sending-email).

Porting notes:

- **Recipients are strings in Postmark and arrays here.** `"a@x.com, b@x.com"` becomes `["a@x.com", "b@x.com"]`. If your code builds that string by joining a list, delete the join rather than the list.
- **`Tag` is one string per message. Our `tags` are pairs, and there can be several.** A tag like `"welcome"` becomes `{"name": "category", "value": "welcome"}`. Pick a stable `name` so your dashboards filter the way your Postmark tag stats did.
- **`Metadata` ports directly, and we echo it back.** We return your `metadata` (and `tags`) on every webhook event alongside `email_id`/`recipient_id`, so your handlers get your context back without a lookup.
- **Click tracking is an enum there and a boolean here.** `TrackLinks: "None"` is `track_clicks: false`; the three enabled values all become `track_clicks: true`, because we do not track HTML and text parts separately.
- **Templates move into the same call.** There is no separate template endpoint: `TemplateId` or `TemplateAlias` becomes `template` (by ID or slug) and `TemplateModel` becomes `template.parameters`, on `POST /v1/email/messages`. See [sending with a template](/docs/guides/email/sending-email#sending-with-a-template).
- **A message stream has no counterpart, and `category` is not one.** A stream is a container carrying its own suppression list, its own stats and its own webhooks. Our [category](/docs/guides/email/categories) is a per-message flag with one effect: it decides which suppression records and unsubscribe preferences may block that message. Setting `category: transactional` because a message came from a transactional stream happens to be right most of the time, but it is a statement about why you are sending rather than a port of the stream. Nothing here reproduces per-stream stats or per-stream suppression scoping; use [tags](/docs/guides/email/sending-email#tags-vs-metadata) for the reporting split.

## Export suppressions

Postmark keeps suppressions **per message stream**, so there is no single account-wide list to pull. For each stream you send on, dump it and run the result through the [import loop](/docs/guides/email/migrate#3-import-suppressions):

- `GET /message-streams/{stream_id}/suppressions/dump`

Enumerate your streams first and dump every one you still send on. Treating the default `outbound` stream as though it were the list carries the transactional suppressions and misses the broadcast ones, and you find out by mailing people who opted out. The `SuppressionReason` values are `HardBounce`, `SpamComplaint`, and `ManualSuppression`, which map onto our `hard_bounce`, `complaint`, and `manual` reasons. [Suppressions](/docs/guides/email/suppressions) has the full taxonomy.

## Translate webhook events

Postmark sends one webhook type per event and identifies it by the `RecordType` field in the payload.

| Outcome            | Postmark                         | Bird                                             |
| ------------------ | -------------------------------- | ------------------------------------------------ |
| Accepted/processed | (the API response)               | `email.accepted` → `email.processed`             |
| Delivered          | `Delivery`                       | `email.delivered`                                |
| Temporary failure  | `Bounce` with a transient `Type` | `email.deferred`                                 |
| Permanent bounce   | `Bounce` with `Type: HardBounce` | `email.bounced` / `email.out_of_band_bounce`     |
| Spam complaint     | `SpamComplaint`                  | `email.complained`                               |
| Blocked/suppressed | (none)                           | `email.rejected`                                 |
| Open               | `Open`                           | `email.opened`                                   |
| Click              | `Click`                          | `email.clicked`                                  |
| Unsubscribe        | `SubscriptionChange`             | `email.unsubscribed` / `email.list_unsubscribed` |

Two differences decide how much of your handler changes.

**Verification is new code rather than a swap.** Postmark's documentation states that it "does not currently support HMAC webhook signature verification" (read September 2026), and recommends HTTP Basic credentials embedded in the registered URL (`https://<username>:<password>@example.com/webhook`) plus its IP ranges in your firewall. We sign every delivery per the [Standard Webhooks](https://www.standardwebhooks.com) HMAC scheme, so your handler gains a verification step it did not have. The recipe is in [Webhooks and events](/docs/guides/webhooks). Do this step first: a handler that accepts unsigned requests is the one thing the migration should not carry over.

**Rotate the credentials rather than reusing them.** A username and password that has lived inside a webhook URL should be treated as exposed, because URLs reach access logs, config exports, and a vendor console. Take them out of the endpoint and issue a new pair if anything else still needs them; do not carry the old pair onto the Bird endpoint, which authenticates by signature instead.

**Postmark reports hard and soft bounces as one `Bounce` record with a `Type` field. We report them as different events.** A handler that branches on `Type` inside a bounce payload branches on the event name here instead: transient failures arrive as `email.deferred` and permanent ones as `email.bounced`. Every bounce type Postmark distinguishes is on its Bounce API reference; what matters for the port is which side of that split each one lands on.

Our delivery events are recipient-scoped (`recipient_id` alongside `email_id`), so a send to three recipients produces three delivery outcomes rather than one.

## Cut over

Work through [domains and DNS](/docs/guides/email/migrate#2-re-point-domains-and-dns) and the [sandbox smoke test](/docs/guides/email/migrate#5-verify-in-the-sandbox-before-cutover) in the main guide. Both are provider-independent.

## Next steps

- [Sending domains](/docs/guides/email/sending-domains): registration, verification lifecycle, and the DNS records you are publishing
- [Webhooks and events](/docs/guides/webhooks): endpoint setup and Standard Webhooks verification
- [Testing sandbox](/docs/guides/email/testing-sandbox): smoke-test the new integration before cutover
- [Suppressions](/docs/guides/email/suppressions): confirm your imported list and how we maintain it from here