We use cookies to enhance your experience on the site
CodeWorlds
Back to collections
Guide14 min read

Postmark, transactional email and streams

Postmark sends transactional email and separates it from marketing with streams. Deliverability, pricing, domain authentication, and common mistakes.

Postmark, transactional email and streams

Postmark is a service for sending email from an application: order confirmations, password resets, event notifications. You send through a programming interface or through the ordinary mail protocol, and the service handles delivery and reports what happened to the message.

Its differentiator is one design decision that sounds like a detail and is the heart of the matter: separating transactional email from bulk at the infrastructure level. We return to that, since it is equally the most common source of trouble for people arriving from other services.

The owner and what that changed

The service was acquired by ActiveCampaign in 2022 and continues as a separate product, with its own console and its own documentation. There is no absorption or winding down here of the kind visible in some acquisitions.

Worth knowing, though, that user reviews after that change carry a recurring theme about the service layer: support response times and how account verification matters get handled. Those are user accounts rather than a measurable property of the product, so treat them as a signal to check rather than as fact.

The technology and deliverability remain a strength in those same reviews, and that is in fact the main reason people choose this service.

Message streams

This is the mechanism to understand before sending a first message, since used wrongly it undoes this service's entire advantage.

Messages divide into two kinds. Transactional ones follow from a particular person's action: they ordered, they registered, they asked for a password reset. Marketing ones go to a list of recipients who did nothing in particular.

The service enforces separating them into distinct streams, and each stream carries its own sending address reputation. The consequence is direct: a campaign that some recipients answered with an abuse report does not spoil the deliverability of order confirmations.

Without that separation something happens that is visible in many companies. A newsletter sent to ten thousand people generates complaints, the sending address reputation drops, and a week later password resets start landing in the unwanted folder. Nobody connects the two facts, since time separates them.

The practical rule reads: if a message goes to more than one person at once, it is not transactional, however much it resembles a notification. Sending bulk through the transactional stream breaks this service's rules and is the simplest route to trouble.

Domain authentication

The second thing to do before a first send, and this one concerns every mail service rather than only this one.

Three entries in domain configuration decide whether your mail arrives at all. The first states which servers may send on the domain's behalf. The second adds a cryptographic signature by which a recipient checks the message was not altered. The third states what to do when the first two fail.

Without the first two, some recipients accept the message, some reject it, and some file it as unwanted, and which happens depends on the mailbox provider. Without the third, large mail providers treat the sending more strictly, particularly at higher volumes.

Introduce the third entry gradually. You start with a setting saying observe and report, collect reports for several weeks, check that nothing legitimate falls outside authentication, and only then tighten to rejection. Setting rejection immediately can block sending from systems nobody in the company remembered existed.

Set up a return domain separately, the address to which non delivery notices come back. Without it a mismatch appears between the domain visible to a recipient and the technical domain, which some providers treat as a warning signal.

Sending and handling events

Code
Bash
curl -X POST "https://api.postmarkapp.com/email" \
  -H "X-Postmark-Server-Token: $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "From": "notifications@yourdomain.com",
    "To": "customer@example.com",
    "Subject": "Order 4471 received",
    "HtmlBody": "<p>Thank you for your order.</p>",
    "MessageStream": "outbound"
  }'

The stream field matters most here and gets omitted from examples most often. Leaving it out means using the default stream, which for marketing email is the mistake described above.

In code it looks the same, with one difference worth noting: the library does not throw on a rejected message, it returns an error code in the response.

Code
TypeScript
import { ServerClient } from 'postmark'

const client = new ServerClient(process.env.POSTMARK_TOKEN!)

const result = await client.sendEmail({
  From: 'notifications@yourdomain.com',
  To: customerEmail,
  Subject: 'Order 4471 received',
  HtmlBody: '<p>Thank you for your order.</p>',
  TextBody: 'Thank you for your order.',
  MessageStream: 'outbound'
})

if (result.ErrorCode !== 0) {
  log.error({ code: result.ErrorCode, message: result.Message }, 'send failed')
}

Checking the error code is mandatory here. A call that succeeded at the network level does not mean the message went out, and a non zero code carries a specific cause: an inactive address, an unverified domain, or an exceeded limit.

Message events return through callbacks: bounce, abuse report, open, click. Handle at least the first two, since recipient list hygiene depends on them.

A hard bounce means the address does not exist, and such an address must be permanently excluded from sending. Retrying to non existent addresses is one of the strongest signals lowering reputation. A soft bounce means a temporary problem and there a retry is right.

Code
TypeScript
export async function POST(request: Request) {
  const event = await request.json()

  switch (event.RecordType) {
    case 'Bounce':
      if (event.Type === 'HardBounce') {
        await suppressPermanently(event.Email)
      }
      break
    case 'SpamComplaint':
      await suppressPermanently(event.Email)
      break
  }

  return new Response(null, { status: 200 })
}

Returning a two hundred matters here regardless of what you did with the payload. Any response other than success makes the service retry the call, and with a bug in your code that ends in an avalanche of repeats of the same event.

An abuse report requires immediately excluding the address from all sending, transactional included, though that is debatable for order confirmations. Excluding and letting the user get in touch themselves is the safer route.

Open tracking works by embedding an invisible image, and its reliability has fallen since some mail providers fetch images in advance. Opens reported by such mailboxes do not mean anybody read the message.

Templates and message content

Templates can live on the service side or be assembled in the application, and the choice carries consequences worth knowing early.

A template on the service side has the advantage that a non technical person changes the text with no deployment. Two drawbacks follow: it falls out of code review and out of change history, and when something breaks nobody knows when or by whom it was introduced. For frequently changing text that is often the right compromise; for order confirmations less so.

A template in the application lives in the repository with everything else and passes the same process as code. Writing them as React components adds the convenience familiar from interfaces plus the ability to view the result without sending anything, which while working on a message's appearance is the difference between a minute and fifteen.

Whichever you choose, several rules apply that work differently in email than on a page. Table based layout remains safer than modern layout mechanisms, since some mail clients do not support them. Styles must be written directly onto elements, since external stylesheets get stripped. Treat images as optional, since they are not fetched by default, and a message built entirely from an image looks empty to some recipients.

Always include a plain text version alongside the formatted one, too. Some clients display it instead of the rich version, and its absence is sometimes read as a bulk sending signal.

With a template held on the service side, sending looks different: instead of content you pass a template name and a set of values to substitute.

Code
TypeScript
await client.sendEmailWithTemplate({
  From: 'notifications@yourdomain.com',
  To: customerEmail,
  TemplateAlias: 'order-confirmation',
  TemplateModel: {
    number: '4471',
    amount: '249.00 USD',
    link: 'https://shop.com/orders/4471'
  },
  MessageStream: 'outbound'
})

Referring to a template by its own name rather than a numeric identifier is a small detail with large practical weight: the name carries across environments, while the identifier differs in each one.

Testing and environments

Email is one of the few things that cannot sensibly be tested in production, so it deserves planning.

The service provides a test mailbox accepting everything you send to it and delivering nothing onward. That is the right home for a development environment and automated tests: the code sends for real, so the path gets exercised, while no message reaches a real address.

The most dangerous scenario in email work is sending to real addresses from a copy of the production database. It happens regularly, usually while testing a new template against data somebody copied "just for a moment". The safeguard is a separate key per environment and a rule that the production key does not exist outside production.

With deployment previews, the ones Vercel generates per branch for instance, write that rule into the environment configuration rather than relying on memory. A test branch holding the production mail key is one of those mistakes that cannot be undone.

The last item is checking appearance across providers. The same message looks different in the few most popular clients, and the differences can be large enough that a layout readable in one falls apart in another. Tools previewing across many clients at once save considerable time here.

Postmark against the alternatives

ServiceEmphasisPricing modelPick it when
PostmarkTransactional mail, separated streamsPer message, one hundred free monthlyConfirmation deliverability matters to you
ResendDeveloper experience, React templatesPer message, free tierA new project, templates as components
Large cloud providersScale and priceVery cheap per messageEnormous volume, your own reputation work
Your own mail serverFull controlMachine costVery rarely justified

Settle the last row immediately, since the question keeps returning. Standing up your own mail server is technically simple and operationally hard: building a new address's reputation takes weeks, handling block lists is continuous work, and one configuration mistake can remove you from delivery for a month. For transactional sending it almost never pays.

The third row tempts on price, and it pays to know what that price contains. A very low per message rate usually comes with a sending address pool shared with other customers, so reputation partly depends on what they do. At high volume you take a dedicated address, and then the price difference shrinks.

The choice between the first two rows comes down to what you value. One service emphasises deliverability and traffic separation, the other convenience and templates written as components. Both do the same thing in the basic sense.

One further approach sits outside the table, worth a look if separating streams sounds like needless work to you. Loops runs transactional sending and lifecycle sequences from one panel, so you maintain neither two systems nor two contact lists. Its price, however, follows contact count rather than message count, which works out dearer than per message billing when you write to a large list infrequently.

Pricing and what to compute

Billing follows message count, but there is no single rate. The free developer plan carries one hundred messages a month with no expiry and allows no overage, so sending stops once the allowance runs out. Paid plans start at ten thousand messages and differ not in allowance but in overage price and feature scope: Basic at 15 dollars a month with 1.80 dollars per thousand above the limit, Pro at 16.50 with 1.30, Platform at 18 with 1.20. There is no intermediate step between one hundred and ten thousand.

On paid plans, exceeding the limit stops nothing. The service totals the overage at the end of the period and adds it to the next month's bill, so the bill grows on its own with no decision from you. It does not work the other way: unused messages do not roll over into the following month.

Check two lines before deciding. A dedicated sending address is a separately billed add on from fifty dollars a month per address, and wanting one is not enough: accounts sending from three hundred thousand messages a month upward, on Pro or higher, get one. Below that threshold a shared pool is better, since a single low traffic address builds reputation more slowly.

The second is the event retention period, meaning how long you can check what happened to a particular message. The default is forty five days on every plan, the free one included, rather than a privilege of the dearer ones. Extending it, anywhere from seven to three hundred and sixty five days, is a separately billed add on from five dollars a month, available on Pro and above. In customer support that often matters more than the per message price, since the question of whether somebody received their confirmation comes up daily.

Compute volume honestly too. An application with a thousand users sends more than the registration count suggests: confirmations, reminders, change notifications, reports. The bill becomes predictable only after a week of measuring in production.

Common mistakes

The first is sending marketing email through the transactional stream. It spoils reputation exactly where delivery matters most, and the effects appear with a delay.

The second is no domain authentication, or setting one entry out of three. Some recipients accept the message and some reject it, and diagnosis is hard, since it depends on the mailbox provider.

The third is retrying sends to hard bounced addresses. That is one of the strongest signals lowering reputation and the easiest to avoid by handling a callback.

The fourth is setting a strict authentication policy immediately, with no observation period. It blocks sending from systems nobody remembered, an old invoicing tool for instance.

The fifth is treating opens as a measure of effectiveness. Some providers fetch images in advance, so an open does not mean anybody read it.

The sixth is chasing a dedicated sending address at low volume. Here the three hundred thousand messages a month threshold blocks that, at other vendors nothing blocks it, and a low traffic address builds reputation more slowly than a shared pool and worsens the situation rather than improving it.

FAQ

What are message streams?

A separation of transactional from bulk mail at the infrastructure level, where each stream carries its own sending address reputation. That means a campaign generating complaints does not spoil the deliverability of order confirmations and password resets.

Is Postmark still a separate product?

Yes. The service was acquired by ActiveCampaign in 2022 and continues with its own console and documentation. User reviews since the acquisition carry a recurring theme about the service layer, so it is worth checking yourself before a longer commitment.

What should I set up before the first send?

The three entries authenticating the domain plus a return domain. Introduce the rejection policy gradually, starting with observation mode for several weeks, since setting it immediately can block sending from systems nobody in the company remembered.

When is a dedicated sending address worthwhile?

From three hundred thousand messages a month, since below that the service does not issue one, and the add on costs from fifty dollars a month per address and requires a Pro plan or higher. A shared pool is better below that, since a low traffic address builds reputation more slowly and gets treated more cautiously by mailbox providers.

Can I send a newsletter with this?

Yes, but only through a separate stream intended for bulk sending. Sending campaigns through the transactional stream breaks the service's rules and spoils the deliverability of the messages that matter most.

Documentation sits on the developer site, and streams are described in the deliverability guide.