Guides/The Developer's Guide to Transactional Email/How to Send Transactional Email

Chapter 2

How to Send Transactional Email

The practical core: how a message travels from your application to the inbox, how to choose and compare email service providers, and how to send your first one. Includes runnable code for sending, personalization, templates, and localization.

Charcoal guide cover with aurora gradient panel and Courier mark.

Last updated: June 2026

This chapter is the practical core: how a message actually travels from your application to someone's inbox, how to pick the service that carries it, and how to send your first one. By the end you'll have working code, a framework for choosing a provider, and a plan for templates, personalization, and localization.

How transactional email gets delivered: SMTP, APIs, and providers

When your app sends a transactional email, it hands the message to a mail server, which relays it across the internet to the recipient's mailbox provider, which decides whether to place it in the inbox. You can talk to that mail server in one of two ways: the SMTP protocol or an HTTP API.

SMTP (Simple Mail Transfer Protocol) is the standard language of email. Your code opens a connection to a mail server, authenticates, and streams the message. SMTP works everywhere and is well understood, but it's chatty, slower to send at volume, and gives you limited feedback beyond "accepted" or "rejected."

HTTP APIs are what most modern providers offer on top of SMTP. You make a single HTTPS request with a JSON body, and the provider handles the SMTP relay for you. APIs are faster, easier to debug, and return rich responses (message IDs, validation errors, structured status), which is why most new integrations use them.

Behind either interface sits the hard part: actually getting delivered. The recipient's mailbox provider evaluates your domain's reputation, your authentication records, and how recipients have engaged with your past email, then routes the message to the inbox, the spam folder, or nowhere at all. This is why a dedicated email service provider matters. Running the mail server is the easy part. Maintaining the sending reputation and relationships with mailbox providers that get you to the inbox is the work.

How transactional email gets triggered

Every transactional email starts with an event: a password reset, a cleared payment, a shipped order. The path that event takes to your email system decides two things that matter, how fast the message goes out and how reliably it arrives. Pick the path based on how much each one matters for that message.

  • Direct send. Your app calls the email API in the same request that handled the action. Fewest moving parts, lowest latency, and the right default for anything a user is actively waiting on: reset links, login codes, receipts.
  • Queued send. Your app drops the send onto a queue and a worker picks it up. You trade a little latency for resilience, since spikes get absorbed and failed sends get retried (chapter 4). A good fit once volume grows or a send can wait a beat.
  • Event-driven send. A CDP or event stream like Segment, RudderStack, or Kafka routes product events to your email layer along with everything else. This is how behavior- and lifecycle-based messaging usually gets built, where the trigger is "did X three times this week," not a single request.

The thing to get right is what sits on the critical path. CDPs and event streams are usually quick, but their latency isn't guaranteed and they add a hop between the user and their message, so they're a poor place for something like a login code, where an unpredictable tail and an extra dependency are exactly what you don't want. Keep the messages a user is waiting on close to your app, and let lifecycle messaging flow through the event pipeline where a little variability is fine.

How to choose an email service provider

An email service provider (ESP) is a service that sends your email for you and works to keep it landing in the inbox. The right one depends on five things that actually move the needle, not on feature-list length.

  • Deliverability and reputation management: the core job. Look for established sending infrastructure, dedicated IP options, and a track record with major mailbox providers. This is the single most important criterion.
  • API quality and SDKs: a clean REST API, official SDKs for your language, sandbox or test modes, and clear errors. You'll live in this API, so its ergonomics matter daily.
  • Analytics and visibility: delivery, bounce, and engagement data, plus webhooks for delivery events. You can't fix what you can't see.
  • Pricing model: per-email, monthly volume tiers, or pay-as-you-go. Match it to your actual sending pattern, and check how overages and add-ons (dedicated IPs, validation) are priced.
  • Support and reliability: published uptime, status history, and support that responds when your password resets stop sending.

A few rules of thumb by volume. Under roughly 50K emails a month, most providers' free tiers are plenty and the decision comes down to developer experience. Between 100K and 1M a month, unit economics start to matter, so compare per-message cost carefully. Above that, dedicated IPs and direct deliverability support become worth paying for. And if you have EU data-residency requirements, confirm the provider offers EU infrastructure specifically, because not all of them do.

A useful mental filter: deliverability and visibility are the things you can't easily build yourself, so weight them heavily. Templating and nice dashboards are conveniences you can work around.

Transactional email providers compared

There's no single best provider, only the best fit for your volume, stack, and budget. Here's how the most common transactional email services compare on the criteria that matter.

ProviderInterfaceTemplatingEntry pricing (approx.)Best for
ResendAPI and SMTPReact Email componentsFree 3K/mo; ~$20/mo for 50KModern stacks and developer experience
SendGridAPI and SMTPDynamic templatesFree trial; from ~$20/mo for 50KFull-featured general-purpose sending
Amazon SESAPI and SMTPMinimal (bring your own)~$0.10 per 1,000 emailsHigh volume, AWS-native teams
PostmarkAPI and SMTPTemplate editorFree 100/mo; from ~$15/mo for 10KSpeed-critical transactional email
MailgunAPI and SMTPHandlebars templatesFree trial; ~$35/mo for 10KTechnical teams wanting granular control
MailjetAPI and SMTPVisual builderFree 200/day; from ~$17/mo for 15KMixed technical and non-technical teams
MailerSendAPI and SMTPVisual builderFree 3K/mo; from ~$25/mo for 50KModern SaaS wanting approachable tooling
Mailchimp TransactionalAPI and SMTPTemplatesBlock pricing; ~$80 for 100KExisting Mailchimp customers

Pricing and tiers change constantly, so treat these figures as rough orientation and confirm current rates with each provider before you commit.

A few patterns stand out. Amazon SES is the cheapest at volume but the most barebones: you bring your own templating, suppression handling, and analytics tooling. Postmark optimizes hard for transactional speed and keeps marketing email on a separate stream by design. Resend is the newest and leans into developer experience and React-based templates. SendGrid is the full-featured general-purpose default, and Mailgun gives technical teams granular control with both US and EU regions. For EU data residency specifically, Mailgun and Mailjet offer dedicated EU infrastructure. If cost at scale dominates, SES wins; if developer experience and time-to-first-send dominate, Resend or Postmark tend to win; if you want one well-trodden option, SendGrid is the safe default.

How to send your first transactional email

The fastest path to a sent email is a provider's HTTP API and an SDK. Here's a complete, runnable example using Node.js and SendGrid to send an order confirmation.

First, install the SDK and set your API key as an environment variable:

Copied!

npm install @sendgrid/mail
export SENDGRID_API_KEY="your_api_key_here"

Then send the email:

Copied!

const sgMail = require("@sendgrid/mail");
sgMail.setApiKey(process.env.SENDGRID_API_KEY);
async function sendOrderConfirmation(order) {
const message = {
to: order.customerEmail,
from: "orders@yourstore.com", // must be a verified sender
subject: `Order ${order.id} confirmed`,
text: `Thanks for your order. We're preparing it now. Total: $${order.total}.`,
html: `<p>Thanks for your order. We're preparing it now.</p>
<p><strong>Order ${order.id}</strong> &middot; Total: $${order.total}</p>`,
};
try {
await sgMail.send(message);
console.log(`Confirmation sent for order ${order.id}`);
} catch (error) {
console.error("Send failed:", error.response?.body || error.message);
throw error;
}
}

Three details make this production-shaped rather than a toy. The from address has to be a verified sender on a domain you control, or mailbox providers will reject it. You always send both a text and an html version, because some clients and filters prefer plain text. And you handle the error path, because sends fail and your caller needs to know so it can retry. We'll come back to retries and idempotency in chapter 4.

How to build and manage email templates

A template is a reusable email design with placeholders for the data that changes per send. Hardcoding HTML strings in your application code works for one email and becomes unmaintainable by the tenth, so you'll want to move to templates early.

You have three broad options. Code-based templates keep the markup in your repository using a templating language like Handlebars or a library like React Email; they version with your code and suit engineering-owned email. Provider-hosted templates live in your ESP's dashboard, so non-engineers can edit copy without a deploy, at the cost of splitting your email across two systems. Dedicated template or notification platforms centralize templates across providers and channels, which helps once you outgrow a single ESP.

Whatever you pick, a few practices hold. Separate content from logic so copy changes don't require code changes. Build HTML email defensively, because email clients render with inconsistent, dated engines: use tables for layout, inline your CSS, and test across clients with a tool like Litmus or Email on Acid. Always include a plain-text version. And keep templates under version control or a clear change history, so a broken receipt template is traceable to the change that broke it.

How to use partials and shared components

As your template library grows, you'll notice the same header, footer, button styles, and legal text repeated across every email. Copying that markup into each template means a footer change becomes a find-and-replace across dozens of files, and the one you miss ships broken. Partials solve this: a partial is a reusable fragment, like a header, a footer, or a button, that you define once and include in many templates.

It's the same idea as components in application code. Keep each shared element in one place, reference it everywhere, and a single edit propagates to every template that uses it. Most templating systems support this directly (Handlebars partials, React Email components), and notification platforms usually have an equivalent shared-block concept. Partials are what keep a library of ten or fifty templates maintainable instead of turning every brand tweak into a migration.

How to handle dynamic content and personalization

Personalization is the practice of inserting per-recipient data into a template at send time: a name, an order total, a list of items, a confirmation link. Done well, it's what makes a transactional email useful rather than generic.

Most personalization is straightforward variable substitution, where you pass a data object and the template renders the values:

Copied!

const sgMail = require("@sendgrid/mail");
sgMail.setApiKey(process.env.SENDGRID_API_KEY);
async function sendShippingUpdate(user, shipment) {
await sgMail.send({
to: user.email,
from: "shipping@yourstore.com",
templateId: "d-1234567890abcdef", // a dynamic template stored in SendGrid
dynamicTemplateData: {
firstName: user.firstName,
trackingNumber: shipment.trackingNumber,
estimatedDelivery: shipment.eta,
items: shipment.items, // an array the template can loop over
},
});
}

Two rules keep personalization from backfiring. Escape and validate any user-provided data you render, because an unescaped value is an injection risk and a rendering bug waiting to happen. And always define fallbacks for missing fields, so a null firstName renders "Hi there" instead of "Hi ,". Conditionals (show a block only when data exists) and loops (render a row per order item) cover the rest of what most transactional email needs.

How to handle internationalization and localization

Internationalization (often shortened to i18n) is designing your email system so it can send the right language and regional formatting to each recipient. Localization is the act of producing those translated, region-aware versions. If you have users outside one country, you'll need both sooner than you think.

There's more to it than translating strings. A localized transactional email has to get several things right:

  • Language: the subject, body, and call-to-action text in the recipient's language, including pluralization rules that differ by language.
  • Locale formatting: dates, times, numbers, and currency formatted to the recipient's region ($1,000.00 versus 1.000,00 €).
  • Direction and encoding: right-to-left layout for languages like Arabic and Hebrew, and proper UTF-8 encoding throughout.
  • Locale resolution: choosing the correct locale at send time from the user's stored preference, with a sensible default when it's missing.

The naive approach, maintaining a separate template per language, multiplies your template count and gets unmanageable fast: ten emails in eight locales is eighty templates to keep in sync. The better pattern is one template with localized content resolved at send time, so a change to the layout applies everywhere at once. This is one area where notification infrastructure helps: Courier, for example, stores per-locale translations within a single template and resolves the recipient's locale at send time, so you maintain one template instead of one per language.

Whichever approach you choose, store each user's locale as a first-class field on their profile and pass it with every send. The piece that's easiest to get wrong is resolution: what to do when the stored locale is missing or isn't one you support. Always fall back deliberately rather than letting a template render half-translated.

Copied!

function resolveLocale(user, supportedLocales, defaultLocale = "en-US") {
const preferred = user.locale; // e.g. "fr-CA", or undefined
// 1. Exact match on the stored preference
if (preferred && supportedLocales.includes(preferred)) {
return preferred;
}
// 2. Fall back to the language without the region ("fr-CA" -> "fr-FR")
const language = preferred?.split("-")[0];
const languageMatch = supportedLocales.find((locale) => locale.startsWith(`${language}-`));
if (language && languageMatch) {
return languageMatch;
}
// 3. Fall back to a safe default rather than rendering untranslated
return defaultLocale;
}

That three-step fallback (exact locale, then language, then a safe default) keeps a user with an unusual or missing locale from receiving a broken email, which is the failure mode that turns internationalization from a feature into a bug report.

How to test and preview before you send

Email is unusually unforgiving to ship blind: once it's sent you can't edit it, and it renders across dozens of clients with inconsistent, dated engines. Build a testing step into your workflow before anything reaches a real user.

A few practices cover most of the risk:

  • Send seed tests. Send the real template, with realistic data, to your own addresses across Gmail, Outlook, and Apple Mail before you ship a change.
  • Preview across clients. Tools like Litmus and Email on Acid render your email in dozens of clients at once, catching the Outlook table quirk or the dark-mode inversion you'd never see locally.
  • Check your spam score. A tool like mail-tester.com scores a real send against common filters and flags authentication or content problems before your users hit them.
  • Test the data, not only the layout. Render with missing fields, very long names, zero-item orders, and right-to-left text, because the broken email your users actually get is usually a data edge case, not a design flaw.

Treat a template change like a code change: review it, test it, and keep a way to trace a broken receipt back to the edit that broke it.

Frequently asked questions

Should I use SMTP or an HTTP API to send transactional email?

Use an HTTP API when you can. APIs are faster, return richer responses like message IDs and structured errors, and are easier to debug than SMTP. SMTP is the universal fallback that works when a system can only speak SMTP, but for new integrations an API is the better default.

Which transactional email provider is best?

There's no single best provider; it depends on volume, stack, and budget. Amazon SES is cheapest at scale, Postmark optimizes for transactional speed and deliverability, Resend leads on developer experience, and SendGrid is a full-featured general-purpose default. Match the provider to your sending pattern rather than its feature-list length.

How many email templates do I need?

Fewer than you'd expect if you use partials and localization. Define shared elements like the header, footer, and buttons once as partials, keep one template per message type, and resolve language at send time instead of duplicating templates per locale. That keeps even a large library maintainable.

Previous chapter

What Transactional Email Is

Transactional email has its own rules, infrastructure expectations, and failure modes. Learn what it is, how it differs from marketing email, the common types you'll send, and why something that looks simple gets complicated fast.

Next chapter

How to Land in the Inbox, Not Spam

Getting accepted by a mail server is easy; reaching the inbox is the real challenge. Covers SPF, DKIM, and DMARC authentication, what drives deliverability, how to avoid the spam folder, and how to handle bounces, complaints, and unsubscribes.

Multichannel Notifications Platform for SaaS

Products

Platform

Integrations

Customers

Blog

API Status

Subprocessors


© 2026 Courier. All rights reserved.