Migrate from Postmark to Sendtier
Move a Postmark integration to Sendtier: update DNS, API calls, webhooks, and suppressions, then prepare for cut-over.
A Postmark send becomes POST https://api.sendtier.com/emails with Authorization: Bearer and a key that starts with st_test_ or st_live_. Sendtier does not support cc, bcc, reply_to, attachments, custom headers, hosted templates, SMTP relay, or dedicated IPs. Live sending is limited during the public preview.
Before you start
The API, workers, Postgres database and its backups, and queues run in AWS eu-central-1 (Frankfurt). Email is sent through Amazon SES in that region, and AWS is on the subprocessor list.
Sendtier's TypeScript, Go, and PHP SDKs exist in the repository but are not published to package registries. Use HTTP when replacing your Postmark client.
If your integration uses Postmark SMTP, replace its SMTP transport with HTTP; Sendtier has no SMTP relay.
Render Postmark templates in your application.
Step 1: Add the domain
Create the domain, copy the names from the response, and follow the domains guide.
Getting started with Postmark says a sender signature can send without DKIM or a custom Return-Path, while domain verification requires DKIM. Postmark verification does not verify your domain in Sendtier.
Publish three DKIM CNAMEs. The name is <token>._domainkey.<domain> and the target is <token>.dkim.amazonses.com.
The MAIL FROM host is send.<domain>. Publish an MX record, priority 10, to feedback-smtp.eu-central-1.amazonses.com, and TXT v=spf1 include:amazonses.com ~all on that same host.
If _dmarc.<domain> is empty, publish TXT v=DMARC1; p=none;. If a DMARC record is already there, leave it unchanged.
A pending domain is re-checked every 30 seconds until it verifies, then every 6 hours. A verified domain loses verification only after two failed checks in a row.
Keep both providers' DNS records
Keep Postmark's records while Postmark still sends, and leave mailbox MX records unchanged.
The domains API article shows the TXT name 20131031155228pm._domainkey.domain.com, not a Sendtier CNAME.
The default Return-Path, pm_bounces@pm.mtasv.net, is on Postmark's host (custom Return-Path). A custom one is a CNAME to pm.mtasv.net, usually pm-bounces.<domain>. Getting started says the pm-bounces label can change when the CNAME matches.
If Postmark already uses send.<domain>, its CNAME conflicts with Sendtier's MX and TXT records. A CNAME cannot share that exact DNS name with those records. In that case, add mail.example.com as a separate Sendtier domain, publish its returned records, and send from an address on that subdomain. Its MAIL FROM host is send.mail.example.com.
Postmark's SPF article says an include on the From domain is no longer required, so leave that record alone.
Step 2: Send with a test key
Create a test key, store it as SENDTIER_API_KEY, and follow the quickstart.
POSTMARK_API_TEST is a Postmark server token for a request that is not delivered (API overview), and it is not a Sendtier key. An st_test_ key accepts a send while DNS is pending, but those emails are never delivered and do not reach Amazon SES. The test limit is 1 000 recipients per day, and an st_live_ key needs a verified domain. The free plan allows 3 000 recipients a month and 100 a day. Limits count recipients, not messages, and reset at 00:00 UTC. Pro and Scale are planned and are not on sale (rate limits, pricing).
Step 3: Change the send call
Replace X-Postmark-Server-Token on POST https://api.postmarkapp.com/email with the bearer key. Account calls use X-Postmark-Account-Token instead (API overview). For a new send, the Sendtier key prefix selects test or live mode. A request with resend_of preserves the original email's mode.
The email API takes a comma-separated To, at most 50 addresses across To, Cc, and Bcc, so parse that string into an array.
| Postmark field | Sendtier |
|---|---|
From | from. A live key needs a verified domain. |
To | to, an array of 1 to 50 addresses. |
Cc | Not supported. |
Bcc | Not supported. |
ReplyTo | Not supported. |
Subject | subject, required and nonblank; maximum 998 characters in the API contract. |
HtmlBody | html |
TextBody | text. Send html, text, or both. |
Attachments | Not supported, including a ContentID image. |
Headers | Not supported. |
Tag | tags, a map of strings. Store the tag as tags.category if you want one key. |
Metadata | tags, when the values are strings. Convert other values in your code. |
MessageStream | No field on the send body. |
TrackOpens, TrackLinks | Not supported. |
TemplateId, TemplateAlias, TemplateModel | Not supported. Render the content first. |
| No schedule field | scheduled_at, ISO 8601 UTC, at most 72 hours ahead. |
| No idempotency header | Optional Idempotency-Key. |
| No Postmark field | resend_of, the id of an earlier Sendtier email. |
Postmark cannot schedule a send (scheduling) and does not currently support an idempotency key (idempotency). For a completed Sendtier request, retry the same endpoint with the same key and exact body bytes within 24 hours to replay the stored response. A changed body returns 409 idempotency_conflict.
POST /email/batch accepts up to 500 messages and 50 MB, including attachments. HTTP 200 can still hold a per-message failure, so check each ErrorCode (email API). Sendtier's POST /emails/batch takes an emails array of 1 to 100 messages within a 10 MiB request. A valid batch returns 202, and if any message is invalid, none is accepted.
Postmark
const response = await fetch("https://api.postmarkapp.com/email", {
method: "POST",
headers: {
"X-Postmark-Server-Token": process.env.POSTMARK_SERVER_TOKEN,
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
From: "Example <hello@example.com>",
To: "user@example.net",
Subject: "Your receipt",
TextBody: "Thanks for your order.",
}),
});
const result = await response.json();
if (!response.ok || result.ErrorCode !== 0) throw new Error(result.Message);Sendtier
Replace the sender with an address on the domain you added. Use a different idempotency key for each logical email, and reuse it only for retries.
const response = await fetch("https://api.sendtier.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SENDTIER_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "receipt-123",
},
body: JSON.stringify({
from: "Example <hello@mail.example.com>",
to: ["user@example.net"],
subject: "Your receipt",
text: "Thanks for your order.",
}),
});
if (!response.ok) throw new Error(await response.text());
const { id: emailId } = await response.json();
// Store emailId with your application record.Step 4: Change webhooks
Create the endpoint with a full key, use data.email_id, and use the event id to skip a repeat (webhooks). Trigger names come from the webhooks API.
| Postmark trigger | Sendtier |
|---|---|
Delivery | email.delivered |
Bounce | email.bounced |
SpamComplaint | email.complained |
Open | Not sent. |
Click | Not sent. |
SubscriptionChange | No matching event. |
| Inbound webhook | No matching event. |
| SMTP API error | No matching event. The send returns 422 recipient_suppressed. |
Sendtier also delivers email.delayed, and email.failed when the provider fails. The SES configuration publishes no open or click events, so SES adds no tracking pixel and rewrites no links.
The webhooks overview says Postmark does not currently support HMAC signature verification, and recommends HTTP Basic Auth in the URL plus an IP allowlist. Sendtier uses Sendtier-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256> over <t>.<raw body>, with five minutes of skew. For 24 hours after rotation the previous secret still matches, and the header has two v1 values (Verify Sendtier-Signature).
The overview retries 5xx, 408, 429, and network failures, drops other 4xx responses, and waits 1, 5, 10, 10, 10, then 15 minutes. The bounce webhook says any non-2xx response is retried. The pages disagree about which HTTP responses trigger retries. The overview gives one schedule for outbound events; confirm the retry conditions with Postmark. Sendtier waits 30 seconds, then 1, 5, 15, and 30 minutes, then 1, 2, 4, and 8 hours, for up to 24 hours. You can inspect or resend a delivery (webhooks).
Step 5: Copy suppressions
Before the switch, dump each stream with GET /message-streams/{stream_id}/suppressions/dump. The suppressions API returns EmailAddress, SuppressionReason, Origin, and CreatedAt, and accepts at most 50 creates per call.
Sendtier has no bulk import, so add one address per POST /suppressions call, using a full key.
{
"email": "blocked@example.com",
"reason": "manual"
}reason is manual. Keep Postmark's reason, origin, and created time yourself. Each Postmark list belongs to one message stream, while Sendtier has one list, so a copied address blocks later sends (Suppressions). Each API key allows 10 requests per second with burst capacity 20. When the API returns 429 rate_limited, wait for the Retry-After interval before retrying.
A Permanent bounce or a complaint adds the address, while a Transient or Undetermined bounce does not. The SES type decides, separate from the SMTP digits. A suppressed recipient returns 422 recipient_suppressed. Postmark can list bounces and return a dump (bounce API).
Step 6: Cut over
Live sending is limited during the public preview. Keep production on Postmark until Sendtier announces full live sending. Until then, use an st_test_ key. After the announcement, confirm delivery, webhook handling, suppression imports, and recipient caps before switching production.
To roll back, route new sends through the original Postmark code path. Copy new Sendtier suppressions into the Postmark streams you will resume. Keep both DNS configurations and webhook handlers. Switching providers does not cancel accepted Sendtier emails; check queued and scheduled mail before resubmitting it.
An email.bounced event keeps the SES bounce type (Permanent, Transient, or Undetermined), subtype, and each recipient's action and status, such as 5.1.1. When the receiving server returns a diagnostic code, the event includes it, for example smtp; 550 5.1.1 ... user unknown. The reporting MTA and remote MTA IP are included when present, and SES does not always include a diagnostic code. A delivery event keeps the receiving server's SMTP response and the remote MTA.
Emails, events, and webhook deliveries are kept for 30 days by default, then removed through daily partition retention. The database stores sender, recipients, subject, HTML and text, tags, status, and delivery events, and does not store raw MIME. Owners can export, or delete the organization, with a 30-day restore window (retention, comparison).
Start free with a test key.