New: campaigns, automations and product emails in the same account. Meet Postal
Sign in Create account

From Postmark to Postal, and what has no match

Sending via JSON is similar, but the field names, the key header, the webhooks and the idea of Message Streams are different. This guide shows what has a match and what you need to organize differently.

Message Streams
No streams
Authentication
Token to Bearer
Security
Signed webhooks

Overview

In Postmark you send with POST /email, with the server token in X-Postmark-Server-Token and fields in PascalCase. In Postal sending is POST /emails, with Authorization: Bearer ps_… and fields in snake_case. The design is similar, but there is one difference in the model: Postal has no Message Streams. If you separate transactional from marketing with streams, you need to organize that differently, and step 4 shows how.

PostmarkPOST api.postmarkapp.com/emailX-Postmark-Server-Token
PostalPOST api.postal.so/emailsAuthorization: Bearer

Only the name changes

Send fields, attachments, headers and SMTP all have a match. Names go from PascalCase to snake_case, and the token goes in Authorization: Bearer.

Moves elsewhere

Tracking becomes a domain setting. Metadata becomes tags with restricted characters. Webhook security becomes the signature on each delivery.

No equivalent

Message Streams, separate suppressions per stream, templates with blocks and nested data, the test token and the 500-message batch.

Postal does not imitate Postmark's API. The only API with a compatibility mode is Resend's: if you already use its SDK, see the Resend guide. For Postmark, the switch happens in your code, with fetch or with Postal's official SDKs for Node.js and PHP.

1

Verify the domain and create a key

Add the domain in the dashboard (or with POST /domains), create the records Postal shows in your DNS and ask for verification. Postal signs with DKIM on the postal selector and uses a postal subdomain for the Return-Path, so check that none of your records already uses those names and keep Postmark's until the cutover is done.

  • The server token, which only sends, corresponds to a send-only key. Anyone who also manages templates, suppressions and webhooks through the API needs a full-access key.
  • The key can be restricted to one domain, which helps separate systems. The full token appears only once, at creation.
  • Region: Postal sends from São Paulo by default and lets you choose us-east-1, us-east-2, eu-west-1 or ap-northeast-1 on each domain.
2

Swap the send call

The fields change names and the key changes header. How you tell whether it worked changes too: Postmark responds with ErrorCode in the body, and Postal uses the HTTP status and an error with statusCode, name and message. The success response is just {"id": "…"}. With the Idempotency-Key header, repeating a request does not duplicate the send.

curl · before and after
- curl "https://api.postmarkapp.com/email" \-   -X POST \-   -H "Accept: application/json" \-   -H "Content-Type: application/json" \-   -H "X-Postmark-Server-Token: $POSTMARK_SERVER_TOKEN" \-   -d '{-     "From": "[email protected]",-     "To": "[email protected]",-     "Subject": "Your order #10482 is out for delivery",-     "HtmlBody": "<p>Order <b>#10482</b> is on its way.</p>",-     "Tag": "shipping",-     "MessageStream": "outbound"-   }'+ curl -X POST https://api.postal.so/emails \+   -H "Authorization: Bearer $POSTAL_API_KEY" \+   -H "Content-Type: application/json" \+   -d '{+     "from": "Aurora Cosméticos <[email protected]>",+     "to": ["[email protected]"],+     "subject": "Your order #10482 is out for delivery",+     "html": "<p>Order <b>#10482</b> is on its way.</p>",+     "tags": [{"name": "stream", "value": "transactional"}]+   }'
Node.js · before and after
- const response = await fetch('https://api.postmarkapp.com/email', {-   method: 'POST',-   headers: {-     Accept: 'application/json',-     'Content-Type': 'application/json',-     'X-Postmark-Server-Token': process.env.POSTMARK_SERVER_TOKEN,-   },-   body: JSON.stringify({-     From: '[email protected]',-     To: '[email protected]',-     Subject: 'Your order #10482 is out for delivery',-     HtmlBody: '<p>Order <b>#10482</b> is on its way.</p>',-     MessageStream: 'outbound',-   }),- });- const { MessageID, ErrorCode } = await response.json();+ const response = await fetch('https://api.postal.so/emails', {+   method: 'POST',+   headers: {+     Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,+     'Content-Type': 'application/json',+     'Idempotency-Key': 'order-10482-shipped',+   },+   body: JSON.stringify({+     from: 'Aurora Cosméticos <[email protected]>',+     to: ['[email protected]'],+     subject: 'Your order #10482 is out for delivery',+     html: '<p>Order <b>#10482</b> is on its way.</p>',+     tags: [{ name: 'stream', value: 'transactional' }],+   }),+ });+ if (!response.ok) { /* HTTP status and body { statusCode, name, message } */ }+ const { id } = await response.json();

Field by field

Everything Postmark's send accepts and what happens to each part in Postal.

Send fields: what each one becomes.
PostmarkOn PostalWhat changes
X-Postmark-Server-TokenAuthorization: Bearer ps_…The Accept: application/json that Postmark requires is not needed. The key can be send-only or full-access, and can be restricted to a domain.
FromfromPostmark requires a confirmed Sender Signature. Postal only accepts senders from a verified domain with sending enabled.
To (comma-separated text)to (text or list)Up to 50 addresses. In Postmark the limit of 50 is the sum of To, Cc and Bcc.
Cc, Bcccc, bccText or list.
SubjectsubjectIn Postmark the subject is optional. In Postal it is required when there is no template.
HtmlBody, TextBodyhtml, textAs in Postmark, one of the two is required.
ReplyToreply_toText or list.
Headers (list of {Name, Value})headers (object){"X-Pedido": "10482"}. You cannot override From, To, Subject, Message-ID and other system headers.
Tag (a string, up to 1000 characters)tags: list of {name, value}Up to 50 per email. Name and value accept only ASCII letters, numbers, _ and -, up to 256 characters.
MetadatatagsFor short, simple values, turn each pair into a tag; it comes back in data.tags in webhooks. Data with spaces, accents or longer values stays in your database, linked to the email's id.
TrackOpens, TrackLinksopen_tracking and click_tracking on the domainIn Postmark the choice is per message, including HTML or text. In Postal it is turned on or off on the domain and there is no field on the send. Depends on the plan.
Attachments: Name, Content, ContentType, ContentIDattachments: filename, content, content_type, content_idAlso accepts path, a public URL. Up to 40 MB in total (in Postmark, 10 MB per email, attachments included). Attachments are not supported in batches.
MessageStreamDoesn't existSee the step on Message Streams.
POST /email/batch: up to 500 messagesPOST /emails/batch: up to 100 emailsNo attachments and no scheduling. Everything is validated first: all go through or none. In Postmark the batch returns 200 even with individual failures.
POST /email/withTemplatePOST /emails with templatetemplate.id accepts the ID or the alias. See the templates step.
POST /email/batchWithTemplatesPOST /emails/batch with template on each itemEach item carries its own template.variables.
InlineCssNo matching fieldPostal's documentation does not describe this option.
Response: MessageID, ErrorCode, MessageResponse: {"id": "…"}Success and error come through the HTTP status. The error has statusCode, name and message: test the name, not the text.
Inactive recipient (error 406)Removed from the sendPostal removes suppressed addresses from the send and generates email.suppressed, with no error.
POSTMARK_API_TEST in place of the tokenTest addressesYou send to [email protected] and the other test addresses. It does not use quota and generates real webhooks. Depends on the plan.
3

Recreate the templates

Postmark templates accept blocks and repetitions, like {{#each person}}, and the model (TemplateModel) is an object that can have other objects inside. Postal's only substitute variables, in the {{{NOME}}} format, with three braces, and each value must be text or a number. When your template depends on blocks, move the logic into code: pick between templates or build the HTML yourself and send it in html.

  • In Postal you edit a draft and publish; sends always use the latest published version, and old versions can be restored.
  • Postal also has layouts, to reuse email designs. Do not treat them as identical to Postmark's layouts: recreate and test.
  • In the HTML, values are escaped. A variable with no value and no default value (fallback_value) returns a 422 error.
Template variables
- <p>Hello, {{name}}!</p>- <p>Your order: {{order.id}}</p>+ <p>Hello, {{{NAME}}}!</p>+ <p>Your order: {{{ORDER}}}</p>
Sending with a template
- "TemplateAlias": "order-shipped",- "TemplateModel": { "name": "Ana", "order": { "id": 10482 } }+ "template": {+   "id": "order-shipped",+   "variables": { "NAME": "Ana", "ORDER": "10482" }+ }
4

Decide what to do with Message Streams

In Postmark, a server has streams: transactional (by default, outbound), broadcasts and inbound, up to 10 in total, and suppressions are stored per stream. Postal has no such object. The three uses each have a path, but none of them is a field on the send, and separate suppressions do not exist: the list is single for the team.

  • To isolate reputation, use different domains or subdomains (one for transactional, another for campaigns) and a key restricted to each domain.
  • The pause for a high bounce or complaint rate applies to the whole team, not to a domain or a tag. See the reputation limits in Limits.
  • If your use depends on separate streams with independent suppressions, talk to us before migrating.
Transactional streamThe default, outbound
Normal sendingPOST /emails, via the API or SMTP.
Broadcast streamNewsletters and promotions
CampaignsContacts, segments, topics and unsubscribing. Sending is a campaign, not a field on the email.
Inbound streamReceived emails
InboxesConversations on the platform and the email.received webhook.
Suppressions per streamOne list for each stream
One list per teamA bounce on a campaign also blocks the same address on transactional emails.
Webhooks and stats per streamSeparate per stream
Team webhooksTag your sends, such as stream: transactional, and filter by tag in your code.
5

Point SMTP at Postal

If any system sends over SMTP, change the server, user and password and remove the X-PM-* headers. Messages go through the same rules as the API (verified domain, quotas, suppression list) and show up in Emails and Logs.

Nodemailer · before and after
- host: 'smtp.postmarkapp.com',- port: 587,- secure: false,- auth: { user: process.env.POSTMARK_SERVER_TOKEN, pass: process.env.POSTMARK_SERVER_TOKEN },+ host: 'smtp.postal.so',+ port: 465,+ secure: true,+ auth: { user: 'postal', pass: process.env.POSTAL_API_KEY },
PostmarkPostal
Serversmtp.postmarkapp.com (broadcasts: smtp-broadcasts.postmarkapp.com)smtp.postal.so
Ports25, 2525 or 587, with STARTTLS465 with TLS (recommended) or 587 with STARTTLS. If your network blocks them, 2465 (TLS) or 2587 (STARTTLS).
Username and passwordThe server token for both, or the access key and secret key of an SMTP tokenUser postal and an API key (ps_…) as the password. A send-only key is enough.
Options via headerX-PM-Message-Stream, X-PM-Tag and X-PM-Metadata-*Over SMTP there are no tags, scheduling or templates. These headers would travel with the message as ordinary headers: remove them.
EnableIn the server settingsSMTP is part of the plan. Without it the message is rejected with 550 5.7.1.
6

Register the webhook and check the signature

Create the endpoint in Postal (POST /webhooks, with an HTTPS URL and the list of events) and save the signing_secret. The big change is security: Postmark does not sign deliveries, and Postal signs every one. Compute the signature over the body exactly as it arrived, without parsing and rewriting the JSON.

  • The webhook-id is the same on every attempt: store the ones you have processed to discard duplicates.
  • Respond 2xx right away and process later, in a queue.
  • To test without really sending, send emails to the test addresses or use "Send test event" in the dashboard.
Postal also notifies you of email.sent, email.delivery_delayed, email.failed, email.suppressed, email.replied and others.
Postmark webhookPostal eventNote
Deliveryemail.deliveredThe recipient's server accepted the message.
Bounceemail.bouncedIn data.bounce.type: Permanent, Transient or Undetermined. Postmark uses types such as HardBounce and Transient. A permanent bounce puts the address on the suppression list.
Spam complaintemail.complainedThe address goes on the suppression list.
Openemail.openedRequires open tracking to be turned on for the domain.
Clickemail.clickedCarries the link in data.click.link.
Subscription changesuppression.added and suppression.removedThe closest match: they notify you when an address enters or leaves the suppression list. Check the content before switching over.
Inboundemail.receivedArrives when an inbox receives a message. Receiving on your own domain is available on paid plans.
PostmarkPostal
SecurityNo signature: Postmark does not offer HMAC verification. Protection is basic authentication in the URL and Postmark's list of IPs.Each delivery is signed (Standard Webhooks): webhook-id, webhook-timestamp and webhook-signature headers, with the endpoint's whsec_…. If you used to trust the URL, you now check the signature.
FormatRecordType and the top-level fieldstype, created_at and data
Which email it wasMessageIDdata.email_id, the same id returned on send
RecipientRecipient or Email. A message to several people generates several events.data.to, a list. Treat it as a list.
Tag and metadataTag and Metadatadata.tags, a name → value object
Bounce reasonDetails, Type and TypeCodedata.bounce (type, subType, message, diagnosticCode)
Expected response200Any 2xx within 15 seconds
RetriesRetries 5xx, 408, 429 and network failures, after 1, 5, 10, 10, 10 and 15 minutes. Other 4xx responses discard the event.Any non-2xx response counts as a failure. 8 attempts over about 27 hours, with resending from the dashboard or through POST /webhooks/{id}/events/{event_id}/replay.
Handling · before and after
- if (req.body.RecordType === 'Bounce') {-   // req.body.Email, req.body.MessageID- }+ const event = verifyWebhook(req.body, req.headers, process.env.POSTAL_WEBHOOK_SECRET);+ if (event.type === 'email.bounced') {+   // event.data.to, event.data.email_id+ }
email.bounced · what reaches your server
{
  "type": "email.bounced",
  "created_at": "2026-10-24T13:02:15.004Z",
  "data": {
    "email_id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
    "to": ["[email protected]"],
    "subject": "Your order #10482 is out for delivery",
    "tags": { "stream": "transactional" },
    "bounce": {
      "type": "Permanent",
      "subType": "General",
      "message": "smtp; 550 5.1.1 user unknown"
    }
  }
}
200Respond 2xx within 15 s
7

Bring over your suppressions

Do this before the first real send, so you do not write to people who already opted out or repeat bounces. Postal pauses the team's sending if permanent bounces go over the limit of the account reputation level (3% for a new account) or complaints go over 0.3%; that is why a clean list matters. The example reads the list of one Postmark stream and sends it to Postal in blocks of 100. Repeat for each stream and use a full-access key.

Node.js · read from Postmark and send to Postal
- const base = 'https://api.postmarkapp.com/message-streams';- const dump = await fetch(`${base}/${stream}/suppressions/dump`, {-   headers: {-     Accept: 'application/json',-     'X-Postmark-Server-Token': process.env.POSTMARK_SERVER_TOKEN,-   },- }).then((r) => r.json());- const emails = dump.Suppressions.map((s) => s.EmailAddress);+ for (let i = 0; i < emails.length; i += 100) {+   await fetch('https://api.postal.so/suppressions/batch/add', {+     method: 'POST',+     headers: {+       Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,+       'Content-Type': 'application/json',+     },+     body: JSON.stringify({ emails: emails.slice(i, i + 100) }),+   });+ }
In PostmarkOn PostalHow to bring it over
Suppressions per stream (/message-streams/{stream_id}/suppressions)One list per teamMerge the lists from all streams and send them to Postal. A suppressed address is blocked for all of the team's sends.
HardBouncebounce originWhat you add through the API enters as manual; the bounce and complaint origins are recorded by Postal from its own sends.
SpamComplaintcomplaint originImport it into the suppression list and keep it there.
ManualSuppressionmanual originGoes in through the same endpoint.
Up to 50 addresses per callUp to 100 addresses per callPOST /suppressions/batch/add, with a full-access key.
Recipient origin (the person themselves)Contact with unsubscribed: truePeople who asked to opt out of marketing emails should not go on the suppression list, because it also blocks transactional emails. Use POST /contacts/batch.
8

Switch traffic gradually

The IDs are different: the MessageID of a Postmark email does not exist in Postal. During the switch, record which provider each email came from and keep receiving Postmark webhooks for what went out through it. Send each flow through a single provider, so you don't duplicate sends or pause everything because of wrong emails.

Before you flip the key

  • Domain verified in Postal, with the old records still in place.
  • Send-only key, restricted to the domain, kept out of the code and the repository.
  • Decision made about each Message Stream: regular sending, broadcast or inbox.
  • Suppressions from all streams imported, and marketing unsubscribes imported as unsubscribed contacts.
  • Templates recreated with {{{VARIÁVEL}}}, published, and the block logic moved into code.
  • Metadata that goes into tags renamed to letters, numbers, _ and -.
  • Webhook created, signature checked against the raw body and a 2xx response within 15 seconds.
  • Tests done with [email protected] and [email protected].
  • One flow at a time, watching bounces in the first few days.

Common questions

Does Postal have Message Streams?

No. In Postal, transactional and marketing email are separated by using different services (sending by API or SMTP on one side, broadcasts and contacts on the other), with separate domains and with tags. The suppression list and webhooks belong to the whole team.

Can I keep Postmark while I migrate?

Yes, and it is the safest way. Use both accounts side by side and switch one flow at a time. Only remove the Postmark records once nothing goes out through it anymore.

Does my Postmark event history come along?

No. Postal does not import emails or events from another platform, and it keeps yours for 30 days. Export what you need before you shut Postmark down.

Migrating from Postmark?

Create your account and start on the Free plan. To plan quota, warm-up and the order of the migration, talk to us through the contact form with your domain and daily volume.