Migrating from Postmark
Keep the Postmark client, keep every call, and change the one line that says where it points. Bring the suppression list first.
emails.sh serves a Postmark-compatible API at https://api.emails.sh/postmark. It speaks their exact request format, their exact response format (To, SubmittedAt, MessageID, ErrorCode, Message), and their exact error shape ({ErrorCode, Message} with a 422), so their official client talks to it without noticing. Their server token becomes an esh_ key and goes in the same X-Postmark-Server-Token header.
# No Postmark SDK reads an env var for its host, so this is one line of config.
# PHP
PostmarkClientBase::$BASE_URL = 'https://api.emails.sh/postmark';
# .NET
new PostmarkClient(serverToken, "https://api.emails.sh/postmark");
# Python
ServerClient(server_token, base_url="https://api.emails.sh/postmark")
# Ruby, the only client of the six with a path prefix option
Postmark::ApiClient.new(token, :host => 'api.emails.sh',
:path_prefix => '/postmark/')Every language, and how to point it here
These were read from each SDK's source, not from its documentation. The headline is that none of the six takes an environment variable, so unlike a Resend migration this is a code change, though a one-line one. Four can be pointed at a path under a host. Two cannot: postmark.js resolves its URL as scheme plus requestHost with the endpoint appended, and postmark-java's customApiUrl is a bare hostname the same way, so neither has anywhere to put the /postmark prefix. Postmark ships no Go client at all.
| Language | Package | How to point it at emails.sh | Change |
|---|---|---|---|
PHP | postmark-php | PostmarkClientBase::$BASE_URL = "https://api.emails.sh/postmark" | one line, global |
.NET / C# | Postmark | new PostmarkClient(token, "https://api.emails.sh/postmark") | constructor |
Python | postmark | ServerClient(token, base_url="https://api.emails.sh/postmark") | constructor |
Ruby | postmark | :host => "api.emails.sh", :path_prefix => "/postmark/" | options hash |
Node / TypeScript | postmark | Not reachable: requestHost is a hostname with no path. Use @emails.sh/sdk. | switch SDK |
Java | com.postmarkapp:postmark | Not reachable: customApiUrl is a hostname with no path. Use @emails.sh/sdk. | switch SDK |
Go | no official client | Postmark ships no Go SDK. Use @emails.sh/sdk. | switch SDK |
Ruby is the one that thought of it: Postmark::HttpClient carries a :path_prefix option alongside :host, so it reaches /postmark/ cleanly. For Node and Java, install @emails.sh/sdk instead. The send call takes the same fields with our spellings, and it is a smaller change than a fork.
What the compatibility endpoint covers
/postmark/emailSend one. Answers To, SubmittedAt, MessageID, ErrorCode, Message, exactly as theirs does.
/postmark/email/batchUp to 100 in one call, answering 200 with per-message ErrorCode as theirs does.
/postmark/email/withTemplateTemplateAlias is our template slug; TemplateModel is our variables.
/postmark/email/batchWithTemplatesThe {"Messages":[...]} form of the same thing.
/postmark/bouncesYour suppression list, as their bounce list with their type codes.
/postmark/message-streamsThe one transactional stream we serve.
/postmark/message-streams/:id/suppressions/dumpThe suppression list. The endpoint a migration in either direction reads.
/postmark/domainsSending domains, with their four verification booleans.
/postmark/domainsAdd one, and get back the DNS records to publish.
/postmark/domains/:idOne domain and its records.
/postmark/domains/:id/verifyDkimCheck the DNS now rather than waiting for the sweep.
/postmark/templatesStored templates. Alias is the slug /email/withTemplate takes.
/postmark/webhooksWebhook endpoints, in their Triggers shape.
These are a translation over the same /v1 handlers everything else uses, not a second implementation. A send through the compatibility endpoint passes the same suppression check, the same quota, the same spend cap, and the same idempotency table as a send through /v1/emails, because none of that logic lives in the compatibility layer and none of it can be skipped by using it.
Postmark splits its tokens: a server token sends and reads bounces, an account token lists servers and domains. We have one kind of key and it is scoped to a workspace, so either header authenticates any endpoint here. A tool that reached /postmark/domains with a server token gets the answer rather than the 401 it would get at Postmark.
What differs
- Message streams
- The one structural difference, and the one refused rather than mapped. Postmark separates transactional and broadcast mail onto streams with independent reputation, which is a good design and the main reason people choose them. We serve one transactional stream, "outbound". A send naming any other stream answers their error code 1236 with a sentence saying what to do instead, because quietly accepting broadcast traffic onto a transactional path would undo exactly the isolation you picked streams for. The mechanism underneath a stream is separate reputation, and here that is a separate sending subdomain: verify news.yourdomain.com alongside yourdomain.com and send campaigns from it.
- Suppressions are per stream there, per workspace here
- A full Postmark export has to dump every stream, not just outbound, or every broadcast bounce is left behind. The migrate command lists the streams first and dumps all of them for exactly this reason.
- Click tracking
- TrackLinks must be "None": anything else is refused, because a message that asked for HtmlOnly would go out untracked and you would not know. TrackOpens is accepted and ignored on this surface rather than refused, since a missing open is not a correctness problem. Both kinds of tracking do exist on broadcasts, at
/docs/broadcasts, and neither is reachable through a Postmark client. - Inline attachments
- Attachments[].ContentID is refused. We do not set Content-ID on attached parts, so a cid: reference in your HTML would not resolve and the image would show as broken. Host the image and reference it by https URL.
- Batch size
- One hundred messages per call against their five hundred. A larger batch answers their error code 410 with the number of calls it needs to become. The 200-on-partial-failure contract is preserved: per-message failures come back inside the array with a non-zero ErrorCode, as theirs do.
- Sender signatures
- We verify domains rather than individual addresses, so any local part on a verified domain sends without being registered first. There is nothing to copy and
/postmark/sendersanswers with a sentence saying so. - Templates
- TemplateAlias is our template slug and TemplateModel is our variables, so a template recreated here under the same alias needs no change to the send. What does not survive is the syntax: Postmark uses Mustachio with conditionals and iteration, and ours is {{variable}} substitution only. InlineCss is accepted and ignored, so write inline styles when you recreate a template.
- Message ids
- Both are UUIDs, but they are different UUIDs. Mail you sent through Postmark before switching stays in their activity log, and its MessageID will not resolve here.
Bringing the account across
The code is one line, but an account is also domains, webhook endpoints, templates, and a suppression list that took years of bounces to build. One command reads them from Postmark and imports what it can. It prints a plan and waits for a yes before writing anything, and it is safe to run again.
npx @emails.sh/cli migrate postmark
# reads POSTMARK_SERVER_TOKEN, or pass --from-key
# imports the suppression list first, then domains, webhooks, and templates
# prints a plan and asks before writing anythingSuppressions are written straight to your list here rather than left in a file for you to upload later, and they are written before any domain is added, so an interrupted run is one where the half that finished is the half that mattered. A copy is saved as postmark-suppressions.csv either way.
Two things cannot be copied and the command says so rather than leaving you to find out. Domain verification is a claim on DNS rather than a row, so each domain added here returns its own records to publish; a domain can carry several DKIM selectors at once, so both providers can sign for it while you compare. And API keys are shown once at Postmark as they are here, so they cannot be read back and have to be recreated.
Cutting over
When you are ready to stop being compatible, move to /v1. It is the same message with From spelled from, To as an array rather than a comma-separated string, HtmlBody as html, and Headers as an object rather than a list of pairs. There is no deadline: the compatibility endpoint is a supported surface, not a temporary bridge.