PosthasteDocsGet an API key

Reference

Templates

Store the content here and send it by name. Changing the wording of a receipt stops being a deploy of your application, and a delivery record can say what the message actually contained — even after the template has moved on.

A published version never changes

Editing a template publishes a new version. The old one stays exactly as it was, because messages point at it. That is the whole reason templates are versioned rather than editable: a delivery record that cannot say what it contained is not a record.

A send pins the version it used. Edit the template ten times afterwards and last month’s message still reports the content that actually went out.

The database refuses an update to a published version, not just the API. There is no path — including a direct UPDATE — that rewrites what a message said.

Variables are declared, and a missing one is refused

Write {{first_name}} in the subject, the HTML or the text, and declare it in variables. Declared rather than inferred, because inferring the list from the body means a typo silently becomes a new optional variable.

A missing required variable is a 422, never a blank. Most template engines resolve an unknown variable to an empty string, so a typo sends “Hi ,” to a customer and nothing anywhere reports a problem. You would find out from a support ticket. We refuse the send instead and name every variable that was missing.

Mark one { "name": "x", "required": false } to have it render as empty when absent. A tag used in the body but never declared is refused at publish time, so {{frist_name}} is caught by whoever is editing rather than by whoever receives the blank.

Escaping follows the part

PartEscaped?Why
htmlYes, including both quote charactersA value lands inside markup you wrote, and one of the places it lands is an attribute.
subjectNoIt is a header, not markup. & in a subject line is a visible bug in every mail client.
textNoThe reader is looking at raw text by definition.

Substitution is all this is. There are no loops, conditionals or expressions — a body is authored by whoever holds an API key, and the moment it can branch it can also probe.

Endpoints

POST/v1/templatestemplates:write
GET/v1/templatestemplates:read
GET/v1/templates/:idtemplates:read
GET/v1/templates/:id/versionstemplates:read
POST/v1/templates/:id/previewtemplates:read
PATCH/v1/templates/:idtemplates:write
DELETE/v1/templates/:idtemplates:write

PATCH with any content field publishes a new version. A name on its own is a rename and creates nothing — what a template is called is not part of what a message said.

DELETE is refused once anything has been sent from the template. Deleting it would erase what those messages contained.

How many templates you may store is set by your plan: Free 3, Starter 25, Growth 100, Scale unlimited, Enterprise unlimited. Creating one past the cap returns 403 with "type": "template_limit_reached", carrying the limit and how many you have. Versions are not counted — revising a template you are already allowed is not a new template. The full grid is in limits.

Preview renders; it does not send

POST /v1/templates/:id/preview returns the rendered subject, HTML and text. It reports a render failure exactly as a send does — same error type, same list of missing names — so a preview that passes means the send will too.

It deliberately sends nothing. To test for real, send it to yourself: that path has the suppression check, the warmup throttle and the DKIM signing on it, and a preview that skipped those would be testing something other than production.

The response also carries lint — the same pre-send checks POST /v1/emails runs, on the same rendered bytes. lint.blocked means a send of this content would be refused; everything else in lint.findings is advice you are free to ignore. Two checks stay quiet in a preview because it cannot know the answer: it has no listUnsubscribe and no stream, so it never asks for a one-click unsubscribe, and it has no attachments, so an empty-bodied template reads as empty even though a send carrying a file is accepted.

Examples

POST /v1/templates
{
  "slug": "password-reset",
  "name": "Password reset",
  "subject": "Reset your password, {{first_name}}",
  "html": "<p>Hi {{first_name}}, <a href=\"{{link}}\">reset it here</a>.</p>",
  "text": "Hi {{first_name}}, reset it here: {{link}}",
  "variables": [
    { "name": "first_name" },
    { "name": "link" }
  ]
}

201 {
  "id": "tpl_AZLm3kQ8T2Sf9pXbNc7HrQ",
  "slug": "password-reset",
  "latestVersion": { "version": 1, "publishedAt": "2026-08-25T12:00:00.000Z", … }
}

NextMessage streamsSeparate transactional mail from everything else, so a newsletter complaint never withholds a password reset.