Documentation
Sign inGet started

Migrate from Postmark

This page maps Postmark's send payload, suppression dumps, and webhooks to Bird. Follow the main migration guide 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 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.
Code example
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 is one endpoint for both, with the template referenced in a field.
What it doesPostmarkBird
AuthX-Postmark-Server-Token headerAuthorization: Bearer
SenderFromfrom
RecipientsTo / Cc / Bcc (comma-separated, max 50)to / cc / bcc (arrays)
SubjectSubjectsubject
BodyHtmlBody / TextBodyhtml / text (at least one)
Reply-toReplyTo (comma-separated)reply_to (array)
Custom headersHeaders (Name/Value objects)headers (string → string object)
Filterable labelTag (one per message)tags: {name, value} pairs
Round-trip contextMetadatametadata: arbitrary JSON
Stored templateTemplateId / TemplateAlias + TemplateModeltemplate + template.parameters
Open trackingTrackOpenstrack_opens (default true)
Click trackingTrackLinks (None/HtmlAndText/HtmlOnly/TextOnly)track_clicks (boolean, see below)
AttachmentsAttachments (Name, Content, ContentType)attachments
Traffic separationMessageStream(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.
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.
  • 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 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 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:
  • 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 has the full taxonomy.

Translate webhook events

Postmark sends one webhook type per event and identifies it by the RecordType field in the payload.
OutcomePostmarkBird
Accepted/processed(the API response)email.acceptedemail.processed
DeliveredDeliveryemail.delivered
Temporary failureBounce with a transient Typeemail.deferred
Permanent bounceBounce with Type: HardBounceemail.bounced / email.out_of_band_bounce
Spam complaintSpamComplaintemail.complained
Blocked/suppressed(none)email.rejected
OpenOpenemail.opened
ClickClickemail.clicked
UnsubscribeSubscriptionChangeemail.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 HMAC scheme, so your handler gains a verification step it did not have. The recipe is in Webhooks and events. 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 and the sandbox smoke test in the main guide. Both are provider-independent.

Next steps