Migrate/Postmark
Field rename + auth change

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.

~30 mintypical migration time
3structural changes
8official SDK languages

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.

ConceptPostmarkAnypost
Base URLapi.postmarkapp.comapi.anypost.com/v1
Auth headerX-Postmark-Server-Token: <token>Authorization: Bearer ap_…
Send endpointPOST /emailPOST /v1/email
Batch endpointPOST /email/batchPOST /v1/email/batch
From addressFrom (PascalCase)from (camelCase)
To addresses"[email protected], [email protected]" (comma string)["[email protected]", "[email protected]"] (array)
HTML bodyHtmlBodyhtml
Text bodyTextBodytext
Reply-toReplyToreply_to
Custom headersHeaders: [{Name, Value}]headers: {"X-Name": "value"}
TagsTag: "one-string" (single tag)tags: ["a", "b"] (array)
Arbitrary metadataMetadata: {"key": "value"}No metadata bag; use tags / topic / campaign or custom headers
TemplatesTemplateId + TemplateModeltemplate_id + variables
Response IDMessageIDid
Message streamsMessageStream: "outbound" / "broadcasts"No concept; single stream
WebhooksRecordType per event (PascalCase)type per event (email.*)
SMTP hostsmtp.postmarkapp.comsmtp.anypost.com
SMTP usernameServer API Tokenanypost
SMTP passwordServer 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.

Before (Postmark)
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"
}
After (Anypost)
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.

Postmark
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"
  }'
Anypost
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 RecordTypeAnypost event type
Deliveryemail.deliveredrename
Bounce (hard)email.bouncedsplit
Bounce (soft / transient)email.delayedsplit
SpamComplaintemail.complainedrename
Openemail.openedrename
Clickemail.clickedrename
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.

Postmark webhook payload
{
  "RecordType": "Bounce",
  "Type": "HardBounce",
  "MessageID": "883953f4-…",
  "Email": "[email protected]",
  "From": "[email protected]",
  "BouncedAt": "2026-01-15T10:30:00Z",
  "Tag": "welcome",
  "MessageStream": "outbound"
}
Anypost webhook payload
{
  "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.

Postmark SMTP
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=true
Anypost SMTP
SMTP_HOST=smtp.anypost.com
SMTP_PORT=587
SMTP_USER=anypost
SMTP_PASS=ap_…
SMTP_TLS=true

Postmark'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.