Switch from Postmark to Anypost
Postmark and Anypost are conceptually close: both are simple REST APIs with flat JSON. The work is mechanical. Rename fields from PascalCase to camelCase, swap the auth header, and turn your comma-separated To string into an array.
What's the same, what changed
Both platforms send authenticated email over a JSON REST API and report the same delivery lifecycle. The differences are mostly surface level: field name casing, the auth header, and how recipients and tags are shaped.
| Concept | Postmark | Anypost |
|---|---|---|
| Base URL | api.postmarkapp.com | api.anypost.com/v1 |
| Auth header | X-Postmark-Server-Token: <token> | Authorization: Bearer ap_… |
| Send endpoint | POST /email | POST /v1/email |
| Batch endpoint | POST /email/batch | POST /v1/email/batch |
| From address | From (PascalCase) | from (camelCase) |
| To addresses | "[email protected], [email protected]" (comma string) | ["[email protected]", "[email protected]"] (array) |
| HTML body | HtmlBody | html |
| Text body | TextBody | text |
| Reply-to | ReplyTo | reply_to |
| Custom headers | Headers: [{Name, Value}] | headers: {"X-Name": "value"} |
| Tags | Tag: "one-string" (single tag) | tags: ["a", "b"] (array) |
| Arbitrary metadata | Metadata: {"key": "value"} | No metadata bag; use tags / topic / campaign or custom headers |
| Templates | TemplateId + TemplateModel | template_id + variables |
| Response ID | MessageID | id |
| Message streams | MessageStream: "outbound" / "broadcasts" | No concept; single stream |
| Webhooks | RecordType per event (PascalCase) | type per event (email.*) |
| SMTP host | smtp.postmarkapp.com | smtp.anypost.com |
| SMTP username | Server API Token | anypost |
| SMTP password | Server API Token (same value) | Your ap_… API key |
Two things will touch the most code: (1) Field name casing,
PascalCase to camelCase throughout your send calls and webhook handlers. (2)
The To field: Postmark takes a comma-separated string; Anypost
takes an array of strings.
No metadata bag. Postmark's arbitrary Metadata
object has no direct Anypost equivalent. If you used it to label events for
filtering, move those labels to tags, topic, or
campaign, which travel onto every event for the message. For
arbitrary round-trip data, set a custom X- header instead. See
Tags, topics & campaigns.
Four steps to switch
Postmark and Anypost can run in parallel during cutover; they are fully independent. Migrate one email type at a time and verify delivery before cutting over completely.
Create an Anypost account and verify your domain
Sign up at anypost.com and add the same sending domain you use with Postmark. You'll publish DNS records (an SPF, a DKIM, and a tracking CNAME), then click verify. Propagation takes a few minutes to a few hours. Your Postmark records don't conflict with Anypost's, so both can be live at once. See Domains.
Create an API key
In the Anypost dashboard, create a key (it starts with ap_). Unlike
Postmark's Server Token, the Anypost key goes in the standard
Authorization: Bearer header, so there's no custom header name to
remember. Set it as ANYPOST_API_KEY alongside your existing Postmark
token during cutover. See Authentication.
Update your send calls
Three things change in every call: the auth header, the field names
(PascalCase to camelCase), and the To field (string to array). The
MessageStream field drops entirely; Anypost has a single stream.
POST https://api.postmarkapp.com/email
X-Postmark-Server-Token: <token>
{
"From": "[email protected]",
"To": "[email protected]",
"Subject": "Welcome",
"HtmlBody": "<p>Hi.</p>",
"MessageStream": "outbound"
}POST https://api.anypost.com/v1/email
Authorization: Bearer ap_…
{
"from": "[email protected]",
"to": ["[email protected]"],
"subject": "Welcome",
"html": "<p>Hi.</p>"
}Update your webhook handler
Postmark's webhooks use a RecordType field with PascalCase values
("Bounce", "Delivery"). Anypost uses a type field with
dot-namespaced lowercase values ("email.bounced",
"email.delivered"). Register your URL in the Anypost dashboard and
update your switch statement. See the Webhooks section
below for the full mapping.
The same change in every SDK
The pattern is identical across languages: swap the auth header, rename
fields to camelCase, convert the recipient string to an array, and drop
MessageStream. Pick your language.
curl https://api.postmarkapp.com/email \
-H "X-Postmark-Server-Token: <token>" \
-H "Content-Type: application/json" \
-d '{
"From": "[email protected]",
"To": "[email protected]",
"Subject": "Welcome",
"HtmlBody": "<p>Hi.</p>",
"MessageStream": "outbound"
}'curl https://api.anypost.com/v1/email \
-H "Authorization: Bearer ap_…" \
-H "Content-Type: application/json" \
-d '{
"from": "[email protected]",
"to": ["[email protected]"],
"subject": "Welcome",
"html": "<p>Hi.</p>"
}'Tags: Postmark's Tag accepts a single string.
Anypost's tags is an array, so you can attach several. Wrap your
existing tag in an array ("tags": ["welcome"]) and add more if
you want finer event filtering.
Webhooks
Postmark identifies events with a RecordType field and
PascalCase values. Anypost uses a type field with
dot-namespaced lowercase values. The five core events map across; the
Postmark-specific SubscriptionChange has no Anypost equivalent.
| Postmark RecordType | Anypost event type | |
|---|---|---|
Delivery | email.delivered | rename |
Bounce (hard) | email.bounced | split |
Bounce (soft / transient) | email.delayed | split |
SpamComplaint | email.complained | rename |
Open | email.opened | rename |
Click | email.clicked | rename |
SubscriptionChange | (none) | no equiv. |
Postmark's Bounce is two things in one. Check
the Type field: a HardBounce is permanent (map to
email.bounced and suppress the address); a SoftBounce
or Transient failure is temporary (map to email.delayed).
Anypost splits them so you can suppress on real bounces without reacting to
retries.
Anypost also emits email.suppressed when a send is dropped because the address
is already on your suppression list. There's no Postmark equivalent, and you
don't need to handle it to migrate.
{
"RecordType": "Bounce",
"Type": "HardBounce",
"MessageID": "883953f4-…",
"Email": "[email protected]",
"From": "[email protected]",
"BouncedAt": "2026-01-15T10:30:00Z",
"Tag": "welcome",
"MessageStream": "outbound"
}{
"batch_id": "9c1f2a7b…",
"events": [
{
"type": "email.bounced",
"occurred_at": "2026-01-15T10:30:00Z",
"data": {
"email_id": "email_018f4f3e-7b2c-7c80-8e21-1a3a4f5b6c7d",
"recipient": "[email protected]",
"tags": ["welcome"]
}
}
]
}Anypost pushes events to your endpoint in a batched envelope:
{ batch_id, events: [...] }. Iterate events and
switch on each event's type. The recipient lives at
data.recipient, the message id at data.email_id,
and a click's destination URL at data.tracking.url. See
Webhooks.
// Before (Postmark): RecordType switch, PascalCase fields
app.post("/webhook/email", (req, res) => {
const { RecordType, Type, Email, Recipient, MessageID } = req.body;
switch (RecordType) {
case "Bounce":
if (Type === "HardBounce") suppressAddress(Email);
break;
case "SpamComplaint": unsubscribe(Email); break;
case "Delivery": markDelivered(MessageID); break;
case "Open": recordOpen(Recipient); break;
case "Click": recordClick(Recipient); break;
}
res.sendStatus(200);
});
// After (Anypost): type switch, camelCase data fields
app.post("/webhook/email", (req, res) => {
for (const event of req.body.events) {
switch (event.type) {
case "email.bounced": suppressAddress(event.data.recipient); break;
case "email.delayed": scheduleRetryNotice(event.data.email_id); break;
case "email.complained": unsubscribe(event.data.recipient); break;
case "email.delivered": markDelivered(event.data.email_id); break;
case "email.opened": recordOpen(event.data); break;
case "email.clicked": recordClick(event.data.tracking.url); break;
}
}
res.sendStatus(200);
});Register your webhook URL once in the Anypost dashboard and select which events to receive. Unlike Postmark, there's no per-stream, per-server webhook configuration; one URL covers all events. See Webhooks.
Using SMTP instead of the API?
Postmark uses your Server API Token as both the SMTP username and password.
Anypost uses the literal string anypost as the username and your
ap_ key as the password.
SMTP_HOST=smtp.postmarkapp.com
SMTP_PORT=587
# Server Token used as BOTH username and password
SMTP_USER=<server-api-token>
SMTP_PASS=<server-api-token>
SMTP_TLS=trueSMTP_HOST=smtp.anypost.com
SMTP_PORT=587
SMTP_USER=anypost
SMTP_PASS=ap_…
SMTP_TLS=truePostmark's smtp-broadcasts.postmarkapp.com host for broadcast
streams has no Anypost equivalent; Anypost uses a single SMTP host for all
message types. Supported ports are 587 (recommended),
2587, and 25. STARTTLS is required on every port,
and there's no implicit-TLS port, so if your Postmark config uses port
465, switch to 587. See
Sending over SMTP.
Ready to switch?
Create your account, verify your domain, and send your first email in minutes.