Postmark compatibility

Apps built against Postmark can send through the platform by changing the base URL and the server token. The platform accepts Postmark's request shape on POST /email, POST /email/batch, POST /email/withTemplate and POST /email/batchWithTemplates, answers with Postmark's response shape, and can emit webhooks in Postmark's formats. Everything else uses the native API (REST API, webhooks).

Apps built against Postmark can send through the platform by changing the base URL and the server token. The platform accepts Postmark's request shape on POST /email, POST /email/batch, POST /email/withTemplate and POST /email/batchWithTemplates, answers with Postmark's response shape, and can emit webhooks in Postmark's formats. Everything else uses the native API (REST API, webhooks).

Moving an app over, step by step: moving from Postmark.

Endpoints and authentication

Method Path Body
POST /email one Postmark email object
POST /email/batch array of up to 500 email objects
POST /email/withTemplate one email object naming a template
POST /email/batchWithTemplates { "Messages": [...] }, up to 500 email objects naming a template

The paths sit at the root of the platform host (https://<host>/email), not under /api/v1.

Send the API key in Postmark's header, or as a bearer token:

POST /email HTTP/1.1
Host: messaging.example.com
Content-Type: application/json
Accept: application/json
X-Postmark-Server-Token: mp_live_…

The key must have the SEND permission, and its domain and stream restrictions apply. When both headers are present, X-Postmark-Server-Token wins. These paths accept nothing but an API key: a request without one — including one carrying a signed-in browser's session cookie — is answered 401 with ErrorCode 10 before anything else runs.

Request fields

Field Supported Notes
From yes Required. "Name <address>" allowed. The domain must be a verified domain of the key's tenant and within the key's scope.
To, Cc, Bcc yes Comma-separated strings; quoted display names may contain commas. At most 50 recipients in total.
Subject yes At most 998 characters, no line breaks.
HtmlBody, TextBody yes At least one of them.
ReplyTo yes Comma-separated.
Tag yes Stored as the message's single tag; searchable in the message log; returned as Tag in Postmark-format webhooks.
Metadata yes JSON object; stored with the message and returned in webhooks.
Headers yes [{ "Name", "Value" }]. Reserved headers are refused (From, To, Cc, Bcc, Subject, Message-ID, Date, MIME-Version, Return-Path, Reply-To, Sender, List-Unsubscribe*, Content-*, and the sending service's reserved headers X-SES-*). In-Reply-To and References are accepted and used for threading.
Attachments yes [{ "Name", "Content", "ContentType", "ContentID" }], Content in base64. A ContentID (with or without the cid: prefix) makes the part inline for <img src="cid:…">; it must not contain angle brackets, spaces or line breaks. ContentType must be a MIME type (type/subtype, optional parameters); missing becomes application/octet-stream. A malformed ContentType or ContentID is ErrorCode 300. Total encoded size ≤ 10 MB.
TrackLinks yes "None" turns click tracking off for this message; any other value turns it on. Omitted: the stream's setting.
TrackOpens yes Turns open tracking on or off for this message. Omitted: the stream's setting (on for broadcast, off for transactional by default). An open is recorded when the recipient's mail program loads a tiny invisible image. With TrackOpens: false and links tracked, the image is still there but opens are not recorded.
MessageStream yes See the mapping below.

MessageStream mapping:

MessageStream value Platform stream
missing or empty transactional
any value containing broadcast (case-insensitive), e.g. broadcast, broadcasts, my-broadcast-stream broadcast
anything else, e.g. outbound, transactional, a custom stream id transactional

On a plan with the Mailroomly footer (Free), a broadcast (MessageStream mapped to broadcast) gets a small Sent with Mailroomly line at the end of HtmlBody (before </body>) and TextBody; Postmark adds nothing of the kind.

Broadcast messages get RFC 8058 one-click unsubscribe headers per recipient. In the HtmlBody and TextBody of broadcast messages, Postmark's {{{ pm:unsubscribe }}} placeholder (and the platform's {{unsubscribe_url}}) is replaced with the recipient's hosted unsubscribe link; in transactional messages the placeholders are removed.

Templates

/email/withTemplate and /email/batchWithTemplates take the same fields, but Subject, HtmlBody and TextBody, plus:

Field Notes
TemplateAlias The alias of one of the organization's templates (Campaigns › Templates, or REST).
TemplateId Or the template's number, shown on its page.
TemplateModel The values its fields take: {{name}}, {{order.total}}, and Postmark's {{#each items}}, {{#if}} and {{#unless}} sections, which the platform reads as Mustache sections. Values are escaped; {{{raw}}} inserts a value of the model as HTML.
InlineCss Accepted and ignored: a template from blocks is already inlined, and your own HTML is sent as written.

The template gives the subject, the HTML and the plain text (generated from the HTML when the template has none). A template that does not exist, or neither TemplateAlias nor TemplateId, is ErrorCode 1101. A template is sent on the stream MessageStream names, as any other email: on broadcast it gets the unsubscribe link and the postal address.

The same templates can be sent from the native API (templateAlias or templateId, with model) and from MCP (send_message with template and model).

Responses

Success — HTTP 200:

{
  "To": "joao@exemplo.pt",
  "SubmittedAt": "2026-09-27T10:15:30.412Z",
  "MessageID": "01926a3e-7c1b-7d2e-9f40-6b1d2c3e4f50",
  "ErrorCode": 0,
  "Message": "OK"
}
  • To echoes the request's To string. SubmittedAt is ISO-8601 UTC.
  • MessageID is the platform's message id (a UUID). The same id appears in webhooks (MessageID in Postmark format, messageId in native format) and in GET /api/v1/messages/{id}.
  • The message is queued; delivery happens asynchronously. Suppressed recipients are dropped silently from a message that still has other recipients (the native API reports them in suppressedRecipients), and so, from a domain sent through the platform, are recipients whose domain takes no mail or is a disposable-address service. Suppressions are checked again right before sending, so an address suppressed while the message waits is not sent to either (it shows as a SUPPRESSED event in the message log).

Error — HTTP 422 for a single /email (HTTP 401 for ErrorCode 10), same body shape:

{ "To": "antigo.cliente@exemplo.pt", "ErrorCode": 406, "Message": "You tried to send to recipient(s) that have been marked as inactive." }
Platform condition ErrorCode
Every recipient suppressed (ALL_RECIPIENTS_SUPPRESSED; the message is still logged as SUPPRESSED) 406
Sender domain unknown or not verified (DOMAIN_NOT_VERIFIED) 400
Stream paused (STREAM_PAUSED), key outside its domain/stream scope (SCOPE_DENIED), key without SEND (FORBIDDEN), the tenant's sending paused by platform admins (TENANT_SENDING_PAUSED), broadcast from a starter address (STARTER_BROADCAST_NOT_ALLOWED, e.g. MessageStream: "broadcast"), broadcast from a domain sent through the platform without a DMARC record (BROADCAST_DMARC_REQUIRED), broadcast from an organization whose plan has no marketing sends (BROADCAST_NOT_IN_PLAN), or before an admin of the organization accepted the terms of service, which include consent before marketing (BROADCAST_CONSENT_REQUIRED), or while a Free organization wakes from resting (ORGANIZATION_RESTING: retry in a few minutes) 412
The tenant's daily recipient cap through the platform is reached (DAILY_LIMIT_REACHED), or the API key's own daily recipient limit, from any domain (KEY_DAILY_LIMIT_REACHED) — Postmark's "not allowed to send", as when an account runs out of credits. Resets at midnight UTC. Also the month's emails of a plan without extra (Free) used up (PLAN_VOLUME_REACHED), until the organization's month starts again (the day the error's message names: the 1st, or the day of the month it began paying). 405
From a domain sent through the platform, no recipient left after the recipient checks: every recipient that is not suppressed has a domain that takes no mail or, for broadcast, is a disposable-address service (ALL_RECIPIENTS_INVALID; nothing is stored) 300
No API key (UNAUTHENTICATED), answered with HTTP 401 10
Any other validation error (VALIDATION_FAILED, …) 300

Other HTTP outcomes:

Situation HTTP Body
Invalid, revoked or suspended-tenant key 401 { "ErrorCode": 10, "Message": "Bad or missing Server API token." }
No key at all (a session cookie is never accepted on these paths) 401 { "ErrorCode": 10, "Message": "Bad or missing Server API token." }
More than 500 messages in a batch 400 { "To": null, "ErrorCode": 300, "Message": "At most 500 messages per batch" }
Request body over 15 MB (/email) or 50 MB (/email/batch, Postmark's batch limit); refused before it is parsed 413 { "To": null, "ErrorCode": 300, "Message": "The request body exceeds the maximum size of 15 MB" }
Body is not valid JSON 422 { "To": null, "ErrorCode": 402, "Message": "Invalid JSON" }
More than 100 requests per second for the key 429 { "ErrorCode": 429, "Message": "Rate limit exceeded" }, header Retry-After: 1
Too many bodies at once (32 KB or more each), after waiting up to 10 seconds; nothing is stored, send it again 429 { "ErrorCode": 429, "Message": "Rate limit exceeded" }, header Retry-After: 10

/email/batch answers 200 with one result object per input, in order; each carries its own ErrorCode (0 on success), so a batch can partly succeed. An unexpected server error for one message is reported on that item only, as { "To": "…", "ErrorCode": 500, "Message": "Unexpected error; retry this message" } (500 is not a Postmark code): retry just the items that failed, since the others were queued. A null item gets { "To": null, "ErrorCode": 300, "Message": "Invalid email request: the batch item is null" } and the rest of the batch is still processed; so is an item that cannot be read (for example a null header), which gets its own error. From a domain sent through the platform, the recipient checks look up the domains of the whole batch once, together and within one deadline (3 s by default), rather than message by message.

Not supported

  • Postmark's template management (/templates): templates are created in Campaigns, over the REST API or MCP. Layout templates (LayoutTemplate) are not supported.
  • Postmark's other APIs: bounces, outbound/inbound message search, statistics, servers, message stream management, suppression endpoints, sender signatures, domains, webhook and inbound-rule configuration. Use the native REST API and the admin UI instead (message log, suppressions, domains, apps).
  • Idempotency keys (use idempotencyKey on POST /api/v1/messages).
  • Postmark ids: message and bounce ids are UUID strings, not Postmark's formats; "reactivating" a bounced address is done by removing its suppression.

Postmark webhook formats

Set the app's webhook format to POSTMARK (Integrations → (app), or webhookFormat: "POSTMARK"). Payloads are then shaped like Postmark's. Every request is also signed with X-Messaging-Signature, which Postmark never sent — verifying it is optional for migrated apps but recommended (webhooks).

Common fields on every outbound-event record: MessageStream (outbound for transactional, broadcast), Tag (the first tag, or "") and Metadata. Timestamps are ISO-8601 UTC.

Platform event RecordType Notes
delivered Delivery one per recipient
bounced Bounce HardBounce (TypeCode 1, Inactive: true) for permanent bounces, otherwise SoftBounce (4096); BadEmailAddress (TypeCode 100000, Inactive: true) when validation refused the address before sending (Description InvalidAddress; the address joins the organization's Blocked list)
delivery_delayed Bounce Type: "Transient", TypeCode 2, Inactive: false
complained SpamComplaint TypeCode 512, Inactive: true
opened Open FirstOpen true on the first open of the message
clicked Click
unsubscribed SubscriptionChange one-click or hosted-page unsubscribe
inbound Postmark inbound JSON no RecordType; attachments inline as base64

Delivery

{
  "RecordType": "Delivery",
  "MessageID": "01926a3e-7c1b-7d2e-9f40-6b1d2c3e4f50",
  "Recipient": "joao@exemplo.pt",
  "DeliveredAt": "2026-09-27T10:15:33.120Z",
  "Details": "250 2.0.0 OK  1727432133 x12-20020a05",
  "MessageStream": "outbound",
  "Tag": "invoice",
  "Metadata": { "invoiceId": "123" }
}

Bounce

{
  "RecordType": "Bounce",
  "ID": "01926a3f-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
  "Type": "HardBounce",
  "TypeCode": 1,
  "Name": "Hard bounce",
  "MessageID": "01926a3e-7c1b-7d2e-9f40-6b1d2c3e4f50",
  "Description": "General",
  "Details": "smtp; 550 5.1.1 user unknown",
  "Email": "nao-existe@exemplo.pt",
  "From": "faturas@mail.cliente.pt",
  "BouncedAt": "2026-09-27T10:15:34.004Z",
  "Inactive": true,
  "CanActivate": true,
  "Subject": "A sua fatura",
  "MessageStream": "outbound",
  "Tag": "invoice",
  "Metadata": { "invoiceId": "123" }
}

Description carries the bounce subtype (Suppressed, InvalidAddress or a general SMTP kind) and Details the recipient server's reply, or Mailroomly's words when the sending service refused the message. ID is the platform event id.

A delivery delay is also a Bounce record:

{
  "RecordType": "Bounce",
  "ID": "01926a40-0b1c-7d2e-8f30-4a5b6c7d8e9f",
  "Type": "Transient",
  "TypeCode": 2,
  "Name": "Message delayed",
  "MessageID": "01926a3e-7c1b-7d2e-9f40-6b1d2c3e4f50",
  "Details": "smtp; 421 4.4.2 connection timed out",
  "Email": "joao@exemplo.pt",
  "BouncedAt": "2026-09-27T10:20:01.000Z",
  "Inactive": false,
  "MessageStream": "outbound",
  "Tag": "invoice",
  "Metadata": { "invoiceId": "123" }
}

SpamComplaint

{
  "RecordType": "SpamComplaint",
  "ID": "01926a41-2c3d-7e4f-9a50-1b2c3d4e5f60",
  "Type": "SpamComplaint",
  "TypeCode": 512,
  "MessageID": "01926a3e-7c1b-7d2e-9f40-6b1d2c3e4f50",
  "Email": "maria@exemplo.pt",
  "From": "novidades@mail.cliente.pt",
  "BouncedAt": "2026-09-27T11:02:10.000Z",
  "Inactive": true,
  "Subject": "Novidades de outubro",
  "MessageStream": "broadcast",
  "Tag": "newsletter",
  "Metadata": {}
}

Open

{
  "RecordType": "Open",
  "FirstOpen": true,
  "MessageID": "01926a3e-7c1b-7d2e-9f40-6b1d2c3e4f50",
  "Recipient": "maria@exemplo.pt",
  "ReceivedAt": "2026-09-27T11:00:42.000Z",
  "UserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
  "MessageStream": "broadcast",
  "Tag": "newsletter",
  "Metadata": {}
}

Click

{
  "RecordType": "Click",
  "MessageID": "01926a3e-7c1b-7d2e-9f40-6b1d2c3e4f50",
  "Recipient": "maria@exemplo.pt",
  "ReceivedAt": "2026-09-27T11:01:05.000Z",
  "OriginalLink": "https://cliente.pt/servicos",
  "UserAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)",
  "MessageStream": "broadcast",
  "Tag": "newsletter",
  "Metadata": {}
}

SubscriptionChange

{
  "RecordType": "SubscriptionChange",
  "MessageID": "01926a3e-7c1b-7d2e-9f40-6b1d2c3e4f50",
  "ChangedAt": "2026-09-27T11:03:00.000Z",
  "Recipient": "maria@exemplo.pt",
  "Origin": "Recipient",
  "SuppressSending": true,
  "SuppressionReason": "ManualSuppression",
  "MessageStream": "broadcast",
  "Tag": "newsletter",
  "Metadata": {}
}

Inbound

Sent to the mailbox's inbound webhook URL and to the owning app's webhook (when it subscribes to inbound) — see mailbox inbound webhooks. With the POSTMARK format the body is Postmark's inbound JSON, with attachments inline as base64:

{
  "From": "joao.pereira@exemplo.pt",
  "FromName": "João Pereira",
  "FromFull": { "Email": "joao.pereira@exemplo.pt", "Name": "João Pereira", "MailboxHash": "" },
  "To": "suporte@mail.cliente.pt",
  "ToFull": [ { "Email": "suporte@mail.cliente.pt", "Name": "", "MailboxHash": "" } ],
  "Cc": "",
  "CcFull": [],
  "Bcc": "",
  "BccFull": [],
  "OriginalRecipient": "suporte@mail.cliente.pt",
  "Subject": "Problema com a fatura",
  "MessageID": "CAF3x9Q@mail.exemplo.pt",
  "ReplyTo": "",
  "MailboxHash": "",
  "Date": "Sun, 27 Sep 2026 10:58:12 +0100",
  "TextBody": "Olá, a fatura deste mês tem um valor diferente do habitual.\n\nOn 2026-09-20 ... wrote:\n> ...",
  "HtmlBody": "<p>Olá, a fatura deste mês tem um valor diferente do habitual.</p>",
  "StrippedTextReply": "Olá, a fatura deste mês tem um valor diferente do habitual.",
  "Tag": "",
  "Headers": [ { "Name": "X-Mailer", "Value": "Example Mail 1.0" } ],
  "Attachments": [
    { "Name": "fatura.pdf", "Content": "JVBERi0xLjcK…", "ContentType": "application/pdf", "ContentLength": 48213, "ContentID": "" }
  ]
}
  • MessageID is the sender's Message-ID header without angle brackets.
  • MailboxHash is the part after + in the local part of the recipient (suporte+pedido42@… → pedido42).
  • StrippedTextReply is the text before the first quoted line (>), an On … wrote: / Em … escreveu: line, or -----Original Message-----.
  • Date is ISO-8601 UTC (Postmark sends RFC 2822).
  • Messages whose virus verdict is FAIL are never forwarded.

Differences from Postmark's webhooks

  • Ids (MessageID, ID) are UUID strings.
  • No ServerID, BounceID, DumpAvailable, Content, Geo, Client, OS, Platform, ReadSeconds or ClickLocation fields.
  • Webhook URLs cannot carry basic-auth credentials (https://user:pass@… is refused); authenticate requests with the signature instead.
  • Retries follow the platform schedule (1 min … 24 h, then dead-letter and manual replay), see webhooks.