Skip to content

Templates

Store an email once — subject, HTML and text with {{variables}} — and send it by id with different data each time, so copy can change without a deploy.

How templates work#

  1. Create it

    POST /templates with a name, subject and HTML. New templates are unpublished and start at version 1.

  2. Check it

    POST /templates/:id/render shows the exact subject, HTML and text a set of variables produces, and which variables you left out. Rendering works on unpublished templates.

  3. Publish it

    POST /templates/:id/publish. Sending with an unpublished template fails with 422 Template is not published.

  4. Send with it

    Pass template_id and variables to POST /emails. The template's rendered subject, HTML and text replace any subject, html or text in the same request.

await poststack.emails.send({
  from: 'Acme <hello@yourdomain.com>',
  to: ['ada@example.com'],
  template_id: 'tpl_2wx7c4v9b1n6m3k8q5z0p7ds',
  variables: {
    first_name: 'Ada',
    company_name: 'Acme',
    seats: '5', // values must be strings
  },
});

Editing a published template keeps it published: the next send uses the new content. There is no way to pin a send to an older version — version is a counter, not a history.

Template syntax#

Templates sent through POST /emails are rendered by one small renderer. It supports exactly the following — no loops, filters, partials or else.

Variables

RuleDetail
Shape{{name}} — double braces, no spaces inside. The name is letters, digits and underscores. {{ name }} and {{user.name}} are not variables and are sent as written.
CaseCase-sensitive: {{First_Name}} needs a First_Name key.
ValuesStrings only. variables is an object of string → string; a number or boolean value is rejected with 400. Send "5", not 5.
MissingA variable you don't pass renders as an empty string (except the three below).
EscapingIn the HTML body, values are HTML-escaped (& < > " '), so you cannot inject markup through a variable. Subject and text body get the raw value.

Tags PostStack fills in

These three are not yours to supply. If you leave them out of variables, the renderer keeps them in the HTML and text bodies and the send pipeline fills them in for each recipient:

TagBecomes
{{unsubscribe_url}}A signed one-click unsubscribe link for that recipient (a confirmation page, then a suppression). Putting it in the body also adds RFC 8058 List-Unsubscribe headers to the message.
{{preferences_url}}A signed link to the recipient’s preference centre for subscription topics. It does not add the unsubscribe headers on its own.
{{signature}}Where the sending mailbox's signature goes.

Pass an explicit value (for example your own opt-out page) and your value wins. In the subject these tags are blanked like any unknown variable.

Conditionals

html
{{#if first_name}}Hi {{first_name}},{{/if}}
{{#unless first_name}}Hi there,{{/unless}}

Welcome to {{company_name}}.

{{#if support_url}}Need help? Visit {{support_url}}.{{/if}}
  • {{#if name}}…{{/if}} keeps its content when name is truthy; {{#unless name}}…{{/unless}} when it is not.
  • Truthy means passed and not "", "0" or "false". unsubscribe_url, preferences_url and signature count as truthy when you don't pass them, because they will be filled in.
  • Conditionals are resolved before variables, so the content inside can use variables.
  • They do not nest. An {{#if}} inside another {{#if}} ends at the first {{/if}} and renders wrongly.

Workflows use a smaller subset

A template sent by a workflow's send_email node is not run through this renderer. Only {{first_name}}, {{last_name}}, {{email}} (from the contact) and the three tags above are filled in; any other variable and every {{#if}} block is sent literally. Keep workflow templates to those tags.

Visual builder templates#

In the dashboard, choosing Visual when you create a template lets you compose it from blocks — header, text, button, image, divider, spacer, two columns and footer — instead of writing HTML. The block document is stored in builder_blocks and compiled to email-safe HTML in htmlBody, which is what gets sent. Variables in block text work like anywhere else.

Through the API: while builder_type is visual and builder_blocks has a blocks array, any request that sends html, builder_type or builder_blocks recompiles htmlBody from the blocks — an html you send is overwritten. To edit the HTML by hand, set builder_type to html in the same request.

Versions#

version goes up by one whenever an update touches subject, html, text, builder_type or builder_blocks. Renaming, changing variables, publishing and unpublishing leave it alone. Previous versions are not kept.

Endpoints#

All paths are relative to https://api.poststack.dev. Reading needs the templates:read scope and changing needs templates:manage; full-access API keys have both.

Create a template#

POST
/templates

Create an unpublished template at version 1.

json
{
  "name": "Welcome email",
  "subject": "Welcome to {{company_name}}, {{first_name}}",
  "html": "{{#if first_name}}<p>Hi {{first_name}},</p>{{/if}}<p>Welcome to {{company_name}}.</p><p><a href=\"{{unsubscribe_url}}\">Unsubscribe</a></p>",
  "text": "Welcome to {{company_name}}.",
  "variables": ["first_name", "company_name"]
}
json
HTTP/1.1 201 Created

{
  "template": {
    "id": 77,
    "publicId": "tpl_2wx7c4v9b1n6m3k8q5z0p7ds",
    "teamId": 42,
    "name": "Welcome email",
    "subject": "Welcome to {{company_name}}, {{first_name}}",
    "htmlBody": "{{#if first_name}}<p>Hi {{first_name}},</p>{{/if}}<p>Welcome to {{company_name}}.</p><p><a href=\"{{unsubscribe_url}}\">Unsubscribe</a></p>",
    "textBody": "Welcome to {{company_name}}.",
    "variables": ["first_name", "company_name"],
    "published": false,
    "version": 1,
    "builderType": "html",
    "builderBlocks": null,
    "createdAt": "2026-03-23T10:00:00.000Z",
    "updatedAt": "2026-03-23T10:00:00.000Z"
  }
}
typescript
// The SDK unwraps the { template } envelope.
const template = await poststack.templates.create({
  name: 'Welcome email',
  subject: 'Welcome to {{company_name}}, {{first_name}}',
  html: '<p>Hi {{first_name}},</p><p>Welcome to {{company_name}}.</p>',
  variables: ['first_name', 'company_name'],
});
bash
curl -X POST https://api.poststack.dev/templates \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Welcome email",
    "subject": "Welcome to {{company_name}}, {{first_name}}",
    "html": "<p>Hi {{first_name}},</p>"
  }'

Body parameters

namestringrequired
1–100 characters. Must be unique within your team.
subjectstringrequired
1–998 characters after trimming. May contain variables and conditionals.
htmlstringrequired
The HTML body, at least 1 character. Ignored in favour of the compiled blocks when builder_type is visual (see below).
textstring
Optional plain-text body.
variablesstring[]
The variable names the template expects, each up to 64 characters. Stored for your reference and shown in the dashboard; not enforced.
builder_type"html" | "visual"default html
Authoring mode.
builder_blocksobject | null
The visual builder document: { blocks: [...], backgroundColor?, contentBackgroundColor?, contentWidth? }. Only used when builder_type is visual.

Response fields

template.idinteger
Internal numeric id. Address the template by publicId.
template.publicIdstring
The tpl_… id you pass as template_id when sending.
template.namestring
Unique within your team.
template.subject / htmlBody / textBodystring | null
The source, placeholders unrendered.
template.variablesstring[] | null
The variable list you declared. Informational only — sends are not checked against it.
template.publishedboolean
Only published templates can be sent.
template.versioninteger
Starts at 1; goes up by one on each content change.
template.builderType"html" | "visual"
How the template is authored.
template.builderBlocksobject | null
The visual builder document when builderType is visual.
template.createdAt / updatedAtstring
ISO 8601.

Status codes

StatusMeaning
201Template created.
400Body failed validation — the error names the field.
401Missing or invalid API key.
403insufficient_scope — a sending_access key cannot read or manage templates. Also returned when the team is suspended.
429More than 30 requests a minute to this endpoint from one key. Honour Retry-After.
409A template named "…" already exists — names are unique within your team.

List templates#

GET
/templates

Your team's templates, most recently updated first. Bodies and builder blocks are left out of list rows.

json
{
  "data": [
    {
      "id": 77,
      "publicId": "tpl_2wx7c4v9b1n6m3k8q5z0p7ds",
      "teamId": 42,
      "name": "Welcome email",
      "subject": "Welcome to {{company_name}}, {{first_name}}",
      "variables": ["first_name", "company_name"],
      "published": true,
      "version": 3,
      "builderType": "html",
      "createdAt": "2026-03-23T10:00:00.000Z",
      "updatedAt": "2026-03-23T12:00:00.000Z"
    }
  ],
  "meta": { "page": 1, "perPage": 20, "total": 12, "totalPages": 1 },
  "counts": { "published": 9, "draft": 3 }
}
typescript
// The SDK's list() takes page and per_page only.
const { data, meta } = await poststack.templates.list({ page: 1, per_page: 50 });
bash
curl "https://api.poststack.dev/templates?published=true&search=welcome" \
  -H "Authorization: Bearer sk_live_..."

Query parameters

pageintegerdefault 1
Page number, from 1.
per_pageintegerdefault 20
1–100.
searchstring
Case-insensitive substring match on the name.
published"true" | "false"
Only published or only unpublished templates. Any other value is ignored.

Response fields

data[]object[]
Template rows without htmlBody, textBody and builderBlocks.
meta{ page, perPage, total, totalPages }
Pagination for the filtered list.
counts{ published, draft }
Team-wide totals matching search, ignoring the published filter.

Status codes

StatusMeaning
200The page of templates.
400page or per_page is not a positive integer, or per_page > 100.
401Missing or invalid API key.
403insufficient_scope — a sending_access key cannot read or manage templates. Also returned when the team is suspended.

Get a template#

GET
/templates/:id

One template with its bodies and builder blocks.

json
{
  "template": {
    "id": 77,
    "publicId": "tpl_2wx7c4v9b1n6m3k8q5z0p7ds",
    "teamId": 42,
    "name": "Welcome email",
    "subject": "Welcome to {{company_name}}, {{first_name}}",
    "htmlBody": "{{#if first_name}}<p>Hi {{first_name}},</p>{{/if}}<p>Welcome to {{company_name}}.</p><p><a href=\"{{unsubscribe_url}}\">Unsubscribe</a></p>",
    "textBody": "Welcome to {{company_name}}.",
    "variables": ["first_name", "company_name"],
    "published": false,
    "version": 1,
    "builderType": "html",
    "builderBlocks": null,
    "createdAt": "2026-03-23T10:00:00.000Z",
    "updatedAt": "2026-03-23T10:00:00.000Z"
  }
}
typescript
const template = await poststack.templates.get('tpl_2wx7c4v9b1n6m3k8q5z0p7ds');
bash
curl https://api.poststack.dev/templates/tpl_2wx7c4v9b1n6m3k8q5z0p7ds \
  -H "Authorization: Bearer sk_live_..."

Path parameters

idstringrequired
The template's public id (tpl_…).

Response fields

template.idinteger
Internal numeric id. Address the template by publicId.
template.publicIdstring
The tpl_… id you pass as template_id when sending.
template.namestring
Unique within your team.
template.subject / htmlBody / textBodystring | null
The source, placeholders unrendered.
template.variablesstring[] | null
The variable list you declared. Informational only — sends are not checked against it.
template.publishedboolean
Only published templates can be sent.
template.versioninteger
Starts at 1; goes up by one on each content change.
template.builderType"html" | "visual"
How the template is authored.
template.builderBlocksobject | null
The visual builder document when builderType is visual.
template.createdAt / updatedAtstring
ISO 8601.

Status codes

StatusMeaning
200The template.
401Missing or invalid API key.
403insufficient_scope — a sending_access key cannot read or manage templates. Also returned when the team is suspended.
404Template not found in your team.

Update a template#

PATCH
/templates/:id

Change any field; send only what changes. Published templates stay published, so the next send uses the new content.

json
{
  "html": "<p>Hi {{first_name}}!</p><p>Thanks for joining {{company_name}}.</p>"
}
json
{
  "template": {
    "publicId": "tpl_2wx7c4v9b1n6m3k8q5z0p7ds",
    "htmlBody": "<p>Hi {{first_name}}!</p><p>Thanks for joining {{company_name}}.</p>",
    "published": true,
    "version": 4,
    "updatedAt": "2026-03-23T14:00:00.000Z",
    "...": "every other field, as in Create"
  }
}
typescript
const template = await poststack.templates.update('tpl_2wx7c4v9b1n6m3k8q5z0p7ds', {
  html: '<p>Hi {{first_name}}!</p><p>Thanks for joining {{company_name}}.</p>',
});
bash
curl -X PATCH https://api.poststack.dev/templates/tpl_2wx7c4v9b1n6m3k8q5z0p7ds \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "name": "Welcome email v2" }'

Path parameters

idstringrequired
The template's public id (tpl_…).

Body parameters

namestring
1–100 characters. Must be unique within your team.
subjectstring
1–998 characters after trimming. May contain variables and conditionals.
htmlstring
The HTML body, at least 1 character. Ignored in favour of the compiled blocks when builder_type is visual (see below).
textstring
Optional plain-text body.
variablesstring[]
The variable names the template expects, each up to 64 characters. Stored for your reference and shown in the dashboard; not enforced.
builder_type"html" | "visual"
Authoring mode.
builder_blocksobject | null
The visual builder document: { blocks: [...], backgroundColor?, contentBackgroundColor?, contentWidth? }. Only used when builder_type is visual.

Response fields

template.idinteger
Internal numeric id. Address the template by publicId.
template.publicIdstring
The tpl_… id you pass as template_id when sending.
template.namestring
Unique within your team.
template.subject / htmlBody / textBodystring | null
The source, placeholders unrendered.
template.variablesstring[] | null
The variable list you declared. Informational only — sends are not checked against it.
template.publishedboolean
Only published templates can be sent.
template.versioninteger
Starts at 1; goes up by one on each content change.
template.builderType"html" | "visual"
How the template is authored.
template.builderBlocksobject | null
The visual builder document when builderType is visual.
template.createdAt / updatedAtstring
ISO 8601.

Status codes

StatusMeaning
200Updated (or unchanged, if the body was empty).
400Body failed validation.
401Missing or invalid API key.
403insufficient_scope — a sending_access key cannot read or manage templates. Also returned when the team is suspended.
404Template not found in your team.
409The new name is already used by another template in your team.
429More than 30 requests a minute to this endpoint from one key. Honour Retry-After.

Render a template#

POST
/templates/:id/render

Render with a set of variables and return the result without sending anything. Works on unpublished templates. The three PostStack-filled tags stay as tags in html and text, because they only resolve per recipient at send time.

json
{
  "variables": { "first_name": "Ada" }
}
json
{
  "subject": "Welcome to , Ada",
  "html": "<p>Hi Ada,</p><p>Welcome to .</p><p><a href=\"{{unsubscribe_url}}\">Unsubscribe</a></p>",
  "text": "Welcome to .",
  "missing_variables": ["company_name", "unsubscribe_url"],
  "template_variables": ["company_name", "first_name", "unsubscribe_url"]
}
bash
curl -X POST https://api.poststack.dev/templates/tpl_2wx7c4v9b1n6m3k8q5z0p7ds/render \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "variables": { "first_name": "Ada" } }'

Path parameters

idstringrequired
The template's public id (tpl_…).

Body parameters

variablesobject
String → string, as you would pass to POST /emails. Optional.

Response fields

subjectstring
Rendered subject.
htmlstring | null
Rendered HTML body.
textstring | null
Rendered text body.
missing_variablesstring[]
Names used in the template that you did not pass. Includes unsubscribe_url, preferences_url and signature if the template uses them — those are fine to leave out.
template_variablesstring[]
Every variable name the template uses, including those in conditionals.

Status codes

StatusMeaning
200Rendered.
400variables is not an object of strings.
401Missing or invalid API key.
403insufficient_scope — a sending_access key cannot read or manage templates. Also returned when the team is suspended.
404Template not found in your team.
429More than 60 requests a minute to this endpoint from one key.

Publish a template#

POST
/templates/:id/publish

Make the template sendable. Publishing an already-published template is a no-op that returns 200.

json
{
  "template": {
    "publicId": "tpl_2wx7c4v9b1n6m3k8q5z0p7ds",
    "published": true,
    "...": "every other field, as in Create"
  }
}
typescript
const template = await poststack.templates.publish('tpl_2wx7c4v9b1n6m3k8q5z0p7ds');
bash
curl -X POST https://api.poststack.dev/templates/tpl_2wx7c4v9b1n6m3k8q5z0p7ds/publish \
  -H "Authorization: Bearer sk_live_..."

Path parameters

idstringrequired
The template's public id (tpl_…).

Response fields

templateobject
The template with published: true.

Status codes

StatusMeaning
200Published.
401Missing or invalid API key.
403insufficient_scope — a sending_access key cannot read or manage templates. Also returned when the team is suspended.
404Template not found in your team.
429More than 30 requests a minute to this endpoint from one key. Honour Retry-After.

Unpublish a template#

POST
/templates/:id/unpublish

Take the template out of use. API sends that reference it fail with 422 until you publish it again. Workflows are different: the published check runs when a workflow is activated, so an already-active workflow's send_email node keeps sending the unpublished template to every contact who reaches it.

json
{
  "template": {
    "publicId": "tpl_2wx7c4v9b1n6m3k8q5z0p7ds",
    "published": false,
    "...": "every other field, as in Create"
  }
}
typescript
const template = await poststack.templates.unpublish('tpl_2wx7c4v9b1n6m3k8q5z0p7ds');
bash
curl -X POST https://api.poststack.dev/templates/tpl_2wx7c4v9b1n6m3k8q5z0p7ds/unpublish \
  -H "Authorization: Bearer sk_live_..."

Path parameters

idstringrequired
The template's public id (tpl_…).

Response fields

templateobject
The template with published: false.

Status codes

StatusMeaning
200Unpublished.
401Missing or invalid API key.
403insufficient_scope — a sending_access key cannot read or manage templates. Also returned when the team is suspended.
404Template not found in your team.
429More than 30 requests a minute to this endpoint from one key. Honour Retry-After.

Duplicate a template#

POST
/templates/:id/duplicate

Copy a template — subject, bodies, variables, builder mode and blocks — as an unpublished template at version 1 named 'Copy of <name>'.

json
HTTP/1.1 201 Created

{
  "template": {
    "id": 78,
    "publicId": "tpl_0km4j7h1g8f5d2s9a6q3w0ex",
    "name": "Copy of Welcome email",
    "published": false,
    "version": 1,
    "...": "every other field, as in Create"
  }
}
typescript
const copy = await poststack.templates.duplicate('tpl_2wx7c4v9b1n6m3k8q5z0p7ds');
bash
curl -X POST https://api.poststack.dev/templates/tpl_2wx7c4v9b1n6m3k8q5z0p7ds/duplicate \
  -H "Authorization: Bearer sk_live_..."

Path parameters

idstringrequired
The template's public id (tpl_…).

Response fields

templateobject
The new template.

Status codes

StatusMeaning
201Copy created.
401Missing or invalid API key.
403insufficient_scope — a sending_access key cannot read or manage templates. Also returned when the team is suspended.
404Template not found in your team.
429More than 30 requests a minute to this endpoint from one key. Honour Retry-After.
409More than 50 copies of this template already exist. Rename or delete some, then duplicate again.

Delete a template#

DELETE
/templates/:id

Delete a template permanently. Emails already sent are unaffected; later sends with this template_id fail with 404, and a workflow node that uses it fails for every contact who reaches it.

json
{
  "success": true
}
typescript
await poststack.templates.delete('tpl_2wx7c4v9b1n6m3k8q5z0p7ds');
bash
curl -X DELETE https://api.poststack.dev/templates/tpl_2wx7c4v9b1n6m3k8q5z0p7ds \
  -H "Authorization: Bearer sk_live_..."

Path parameters

idstringrequired
The template's public id (tpl_…).

Response fields

successboolean
Always true on 200.

Status codes

StatusMeaning
200Deleted.
401Missing or invalid API key.
403insufficient_scope — a sending_access key cannot read or manage templates. Also returned when the team is suspended.
404Template not found in your team.
429More than 30 requests a minute to this endpoint from one key. Honour Retry-After.

List presets#

GET
/templates/presets

The built-in starting points: welcome, password-reset, invoice, newsletter, announcement, event-invitation, order-confirmation and feedback-request.

json
{
  "data": [
    {
      "id": "welcome",
      "name": "Welcome Email",
      "subject": "Welcome to {{company_name}}",
      "html": "<!DOCTYPE html>...",
      "variables": ["company_name", "first_name", "dashboard_url", "unsubscribe_url"]
    }
  ]
}
typescript
// Returns the array directly.
const presets = await poststack.templates.getPresets();
bash
curl https://api.poststack.dev/templates/presets \
  -H "Authorization: Bearer sk_live_..."

Response fields

data[].idstring
Preset id, e.g. welcome.
data[].name / subjectstring
Suggested name and subject.
data[].htmlstring
Complete HTML document.
data[].variablesstring[]
Variables the preset uses.

Status codes

StatusMeaning
200The presets.
401Missing or invalid API key.
403insufficient_scope — a sending_access key cannot read or manage templates. Also returned when the team is suspended.

Create a template from a preset#

POST
/templates/presets/:presetId/use

Create an unpublished template at version 1 from a preset, named after it (e.g. 'Welcome Email'). Edit it like any other template.

json
HTTP/1.1 201 Created

{
  "template": {
    "id": 79,
    "publicId": "tpl_5ty8u1i4o7p0a3s6d9f2g5hj",
    "name": "Welcome Email",
    "published": false,
    "version": 1,
    "...": "every other field, as in Create"
  }
}
typescript
const template = await poststack.templates.usePreset('welcome');
bash
curl -X POST https://api.poststack.dev/templates/presets/welcome/use \
  -H "Authorization: Bearer sk_live_..."

Body parameters

presetIdstringrequired
A preset id from List presets, e.g. welcome.

Response fields

templateobject
The new template.

Status codes

StatusMeaning
201Template created.
400presetId is not a preset-style id (lowercase slug).
401Missing or invalid API key.
403insufficient_scope — a sending_access key cannot read or manage templates. Also returned when the team is suspended.
404Preset not found — no preset has that id.
429More than 30 requests a minute to this endpoint from one key. Honour Retry-After.
409A template with the preset’s name already exists — rename it, or create from the preset’s fields under another name.

To use a preset under your own name, create a template from its fields:

typescript
const presets = await poststack.templates.getPresets();
const welcome = presets.find((p) => p.id === 'welcome')!;

const template = await poststack.templates.create({
  name: 'Welcome email',
  subject: welcome.subject,
  html: welcome.html,
  variables: welcome.variables,
});

React Email and other renderers#

PostStack does not render React. If you write emails as components with React Email, render them to HTML in your application and send the result as html — or store the rendered HTML as a template if non-developers should be able to edit it later.

typescript
import { render } from '@react-email/render';
import { WelcomeEmail } from './emails/welcome';

const html = await render(<WelcomeEmail name="Ada" />);

await poststack.emails.send({
  from: 'Acme <hello@yourdomain.com>',
  to: ['ada@example.com'],
  subject: 'Welcome to Acme',
  html,
});

Troubleshooting#

Send fails with 422 “Template is not published”

Publish it with POST /templates/:id/publish, then retry.

Send fails with 400 about variables

Every value in variables must be a string. Convert numbers and booleans (String(seats)) before sending.

A variable came out empty

The key you passed does not match the tag exactly — names are case-sensitive — or the tag has spaces inside the braces. Call render with the same variables and read missing_variables.

Recipients see {{company_name}} or {{#if …}} literally

That usually means the email was sent by a workflow, which only fills in the contact tags. See the warning under Template syntax.

Next steps

Was this page helpful?

Related

Email templates overviewHandlebars and React Email templates, versioning and previews.