What is transactional email? A developer's guide

Transactional email is mail your application sends because a user did something. Here is the API request, the event lifecycle, how it differs from marketing mail, and why region matters.


A transactional email is a message your application sends to one person because of something that person did, or something that happened to their account. A password reset, a receipt, a sign-up confirmation, a security alert and a failed-payment notice are all transactional. The trigger is an event in your product, the recipient expects the message, and it is usually only useful for a short time.

Most explainers stop at that definition and a list of examples. If you are the developer who has to ship it, the useful part comes next: the request you make, what you get back, how you find out the message arrived, and where the data lives. This guide covers those, using Boundry's API for the examples.

What counts as transactional email

  • Account: sign-up confirmation, email verification, password reset, magic sign-in link, one-time codes.
  • Commerce: order confirmation, receipts, invoices, renewal and failed-payment notices.
  • Security: new device sign-in, changed password or email address, suspicious activity.
  • Product activity: a comment on your document, an invitation to a workspace, an export that is ready to download.
  • Operational: a monitor that has gone down, a job that failed, a usage limit that is close.

The test is simple. If the message was not caused by something the person did or something that happened to their account, it is not transactional. A newsletter, a product announcement and a discount campaign go to a list on your schedule. A reset link goes to one person the moment they ask for it.

Transactional email vs marketing email

The two kinds of mail have different jobs and different failure costs. Keep them apart in your code and, ideally, on separate sending domains or subdomains, so a spike in marketing complaints cannot hurt the delivery of a password reset.

  • Trigger: transactional mail is sent by an event in your app. Marketing mail is sent by a person or a schedule.
  • Audience: one recipient who just acted, versus a list or segment.
  • Timing: seconds matter for a sign-in code. A campaign can wait for a good send time.
  • Content: transactional mail carries information the user needs. Keep it to that.
  • Unsubscribe: marketing mail needs a working unsubscribe. Mail that is necessary to run an account or complete a purchase generally does not, but rules differ by country, so check the ones that apply to you. Offer an unsubscribe for anything optional, such as activity digests.

This is general guidance, not legal advice. Boundry is built for transactional mail, so keep the two kinds of sending separate in your own code and domains.

The API call that sends one

Sending is one authenticated HTTP request. In Boundry you create a project API key, verify the domain you send from, and POST to the regional API for your project. A project in Sydney uses api.au.boundry.dev.

curl -X POST https://api.au.boundry.dev/emails \
  -H "Authorization: Bearer $BOUNDRY_API_KEY" \
  -H "Idempotency-Key: reset-7f3a91" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme <hello@acme.com>",
    "to": ["ava@northwind.com.au"],
    "subject": "Reset your Acme password",
    "html": "<p>Use this link within 30 minutes: ...</p>"
  }'

The required fields are from, to, subject, and html or text. Optional fields include cc, bcc, reply_to, headers, attachments, tags and unsubscribe. If you send only HTML, Boundry generates the text alternative and sends both as multipart/alternative. The response includes an id, which you can use to look the message up later with GET /emails/{emailId}.

The from address must belong to a domain you have verified in the same project. Verification means publishing the DNS records the dashboard gives you and waiting until the domain shows as verified.

Make retries safe with an idempotency key

Networks fail halfway. If your request times out you cannot tell whether the message was accepted. A duplicate reset email is an annoyance, but a duplicate receipt is a support ticket. The Idempotency-Key header solves this: send the same key with a retry and Boundry returns the original email id instead of creating a second message. Derive the key from the thing that triggered the mail, such as the reset request id or the order id, not from a random value generated per attempt.

What happens after you send: the event lifecycle

A successful response from the API means Boundry accepted the message, not that the recipient has it. Between acceptance and the inbox the message moves through states, and the ones below can be delivered to your webhook endpoint.

  • email.scheduled: the email is held for a future send time.
  • email.sent: accepted for delivery by the transport.
  • email.delivered: the recipient server accepted the message.
  • email.delivery_delayed: a recipient server deferred the message and retries continue.
  • email.bounced: the recipient server rejected the message. The event carries a bounce classification of Permanent, Transient or Undetermined.
  • email.complained: the recipient marked the message as spam.
  • email.failed: Boundry could not send the message.

There are further events for opens, clicks, unsubscribes and received mail, all listed in the events reference. When you register a webhook you list the exact event names you want, for example email.delivered and email.bounced. Wildcards are not accepted.

Delivered means the recipient's mail server accepted the message. It does not mean the person read it, and it does not promise the inbox over the spam folder. Treat bounced and complained as the signals that matter for your own data. Boundry automatically suppresses addresses that bounce permanently, so check the bounce classification before you act: a transient bounce such as a full mailbox is not a reason to stop sending. Investigate when complaints appear.

Receive the events with a webhook

Instead of polling, register a webhook endpoint with POST /webhooks and the exact events you want. Boundry sends signed, project-scoped events to it, and GET /webhook-events returns the recent events for your project if you need to inspect or reconcile history. Every endpoint has its own signing secret, and deliveries use Svix-style headers: svix-id, svix-timestamp and svix-signature. The svix-id stays the same across retries, so use it to deduplicate, and verify the signature against the raw request body before you parse anything.

import { createHmac, timingSafeEqual } from "node:crypto";

export async function POST(request: Request) {
  const id = request.headers.get("svix-id") ?? "";
  const timestamp = request.headers.get("svix-timestamp") ?? "";
  const signatures = request.headers.get("svix-signature") ?? "";
  const body = await request.text(); // raw body, before any JSON parsing

  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!id || !(age <= 300)) {
    return new Response("stale or missing headers", { status: 400 });
  }

  const secret = process.env.BOUNDRY_WEBHOOK_SECRET!.replace(/^whsec_/, "");
  const expected = createHmac("sha256", Buffer.from(secret, "base64"))
    .update(`${id}.${timestamp}.${body}`)
    .digest();
  const valid = signatures.split(" ").some((entry) => {
    const received = Buffer.from(entry.split(",")[1] ?? "", "base64");
    return received.length === expected.length && timingSafeEqual(received, expected);
  });
  if (!valid) return new Response("invalid signature", { status: 400 });

  // Store the svix-id before processing: retries reuse the same id.
  const event = JSON.parse(body);
  if (event.type === "email.bounced") {
    await reviewBounce(event.data.email_id, event.data);
  }

  return new Response("ok");
}

Signatures are HMAC-SHA256 over the webhook id, the timestamp and the raw body, joined with dots. Rejecting a timestamp more than five minutes old stops a captured request being replayed later. The example above uses only Node's built-in crypto module, so it needs no extra dependency.

The full signature scheme and request headers are in the webhooks guide. Read the webhooks documentation

Why region and storage matter for transactional mail

Transactional mail is some of the most personal data your product handles. A reset link is a credential. A receipt shows what someone bought. A clinic's appointment reminder reveals that a person is a patient. The message body, the recipient address and the event history all sit wherever your email provider stores them.

A provider with a single global platform may store that data in a region you did not choose. That is fine for some products. For an Australian health or finance product, a security reviewer will ask where message content and logs live, and an answer buried in a subprocessor list is a slow answer.

Boundry binds each project to one region when you create it, and the project's message operations and configured application storage run in that region. Sydney is the only live region today. Some things sit outside the boundary and are documented: public requests transit Cloudflare, Clerk handles account identity in the control plane, and once a message is delivered the recipient's mailbox provider holds it.

The architecture is written up so a reviewer can check it. See how regional projects work

Checklist before you send your first one

  • Verify a sending domain, ideally a subdomain such as mail.yourdomain.com, and publish its DNS records.
  • Send transactional mail from a different domain or subdomain than any marketing mail.
  • Use an idempotency key derived from the triggering event on every send that can be retried.
  • Subscribe to email.bounced and email.complained at minimum, and check the bounce classification before you act on it. Boundry suppresses permanent bounces automatically.
  • Verify webhook signatures on the raw body and deduplicate on the svix-id header.
  • Keep the content short and specific: one purpose, one clear action, plain text alongside HTML.
  • Decide which region the data has to stay in before you create the project, because a project's region is permanent.

Frequently asked questions

What is the difference between a transactional and a marketing email? A transactional email is triggered by a user's action or an account event and goes to that one person. A marketing email promotes something and goes to a list on a schedule you choose.

Do transactional emails need an unsubscribe link? Messages that are necessary to operate an account or complete a purchase, such as a password reset or a receipt, generally do not. Optional notifications such as digests should offer one. Rules differ by country. Boundry supports managed one-click unsubscribe: set unsubscribe to true on a send with a single recipient.

How do I know a transactional email was delivered? Subscribe to webhook events. email.delivered means the recipient's server accepted the message, email.bounced means it was rejected (check whether the bounce is Permanent, Transient or Undetermined), and email.delivery_delayed means delivery is being retried.

How do I stop a retry from sending the same email twice? Send an Idempotency-Key header with the request. A retry with the same key returns the original email id instead of creating a duplicate.

How many transactional emails can I send for free? The Sandbox plan includes 3,000 emails a month with one sending domain. The Developer plan is $25 a month for 50,000 emails. Paid checkout is not yet self-serve.

Send your first message in a few minutes. Follow the quickstart

Compare what each plan includes. See pricing

If you want to see what I mean, send something real through Sydney. The Sandbox plan includes 3,000 emails a month. I'd like to hear how it goes.

MitchFounder, Boundry

Create a project when you're ready →