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#
Create it
POST /templateswith a name, subject and HTML. New templates are unpublished and start at version 1.Check it
POST /templates/:id/rendershows the exact subject, HTML and text a set of variables produces, and which variables you left out. Rendering works on unpublished templates.Publish it
POST /templates/:id/publish. Sending with an unpublished template fails with 422Template is not published.Send with it
Pass
template_idandvariablesto POST /emails. The template's rendered subject, HTML and text replace anysubject,htmlortextin 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
},
});curl -X POST https://api.poststack.dev/emails \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <hello@yourdomain.com>",
"to": ["ada@example.com"],
"template_id": "tpl_2wx7c4v9b1n6m3k8q5z0p7ds",
"variables": { "first_name": "Ada", "company_name": "Acme" }
}'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
| Rule | Detail |
|---|---|
| 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. |
| Case | Case-sensitive: {{First_Name}} needs a First_Name key. |
| Values | Strings only. variables is an object of string → string; a number or boolean value is rejected with 400. Send "5", not 5. |
| Missing | A variable you don't pass renders as an empty string (except the three below). |
| Escaping | In 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:
| Tag | Becomes |
|---|---|
{{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
{{#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 whennameis truthy;{{#unless name}}…{{/unless}}when it is not.- Truthy means passed and not
"","0"or"false".unsubscribe_url,preferences_urlandsignaturecount 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.
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#
/templatesCreate an unpublished template at version 1.
{
"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"]
}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"
}
}// 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'],
});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"defaulthtml- Authoring mode.
builder_blocksobject | null- The visual builder document:
{ blocks: [...], backgroundColor?, contentBackgroundColor?, contentWidth? }. Only used whenbuilder_typeisvisual.
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
| Status | Meaning |
|---|---|
| 201 | Template created. |
| 400 | Body failed validation — the error names the field. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — a sending_access key cannot read or manage templates. Also returned when the team is suspended. |
| 429 | More than 30 requests a minute to this endpoint from one key. Honour Retry-After. |
| 409 | A template named "…" already exists — names are unique within your team. |
List templates#
/templatesYour team's templates, most recently updated first. Bodies and builder blocks are left out of list rows.
{
"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 }
}// The SDK's list() takes page and per_page only.
const { data, meta } = await poststack.templates.list({ page: 1, per_page: 50 });curl "https://api.poststack.dev/templates?published=true&search=welcome" \
-H "Authorization: Bearer sk_live_..."Query parameters
pageintegerdefault1- Page number, from 1.
per_pageintegerdefault20- 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
| Status | Meaning |
|---|---|
| 200 | The page of templates. |
| 400 | page or per_page is not a positive integer, or per_page > 100. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — a sending_access key cannot read or manage templates. Also returned when the team is suspended. |
Get a template#
/templates/:idOne template with its bodies and builder blocks.
{
"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"
}
}const template = await poststack.templates.get('tpl_2wx7c4v9b1n6m3k8q5z0p7ds');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
| Status | Meaning |
|---|---|
| 200 | The template. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — a sending_access key cannot read or manage templates. Also returned when the team is suspended. |
| 404 | Template not found in your team. |
Update a template#
/templates/:idChange any field; send only what changes. Published templates stay published, so the next send uses the new content.
{
"html": "<p>Hi {{first_name}}!</p><p>Thanks for joining {{company_name}}.</p>"
}{
"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"
}
}const template = await poststack.templates.update('tpl_2wx7c4v9b1n6m3k8q5z0p7ds', {
html: '<p>Hi {{first_name}}!</p><p>Thanks for joining {{company_name}}.</p>',
});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 whenbuilder_typeisvisual.
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
| Status | Meaning |
|---|---|
| 200 | Updated (or unchanged, if the body was empty). |
| 400 | Body failed validation. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — a sending_access key cannot read or manage templates. Also returned when the team is suspended. |
| 404 | Template not found in your team. |
| 409 | The new name is already used by another template in your team. |
| 429 | More than 30 requests a minute to this endpoint from one key. Honour Retry-After. |
Render a template#
/templates/:id/renderRender 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.
{
"variables": { "first_name": "Ada" }
}{
"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"]
}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
| Status | Meaning |
|---|---|
| 200 | Rendered. |
| 400 | variables is not an object of strings. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — a sending_access key cannot read or manage templates. Also returned when the team is suspended. |
| 404 | Template not found in your team. |
| 429 | More than 60 requests a minute to this endpoint from one key. |
Publish a template#
/templates/:id/publishMake the template sendable. Publishing an already-published template is a no-op that returns 200.
{
"template": {
"publicId": "tpl_2wx7c4v9b1n6m3k8q5z0p7ds",
"published": true,
"...": "every other field, as in Create"
}
}const template = await poststack.templates.publish('tpl_2wx7c4v9b1n6m3k8q5z0p7ds');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
| Status | Meaning |
|---|---|
| 200 | Published. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — a sending_access key cannot read or manage templates. Also returned when the team is suspended. |
| 404 | Template not found in your team. |
| 429 | More than 30 requests a minute to this endpoint from one key. Honour Retry-After. |
Unpublish a template#
/templates/:id/unpublishTake 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.
{
"template": {
"publicId": "tpl_2wx7c4v9b1n6m3k8q5z0p7ds",
"published": false,
"...": "every other field, as in Create"
}
}const template = await poststack.templates.unpublish('tpl_2wx7c4v9b1n6m3k8q5z0p7ds');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
| Status | Meaning |
|---|---|
| 200 | Unpublished. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — a sending_access key cannot read or manage templates. Also returned when the team is suspended. |
| 404 | Template not found in your team. |
| 429 | More than 30 requests a minute to this endpoint from one key. Honour Retry-After. |
Duplicate a template#
/templates/:id/duplicateCopy a template — subject, bodies, variables, builder mode and blocks — as an unpublished template at version 1 named 'Copy of <name>'.
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"
}
}const copy = await poststack.templates.duplicate('tpl_2wx7c4v9b1n6m3k8q5z0p7ds');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
| Status | Meaning |
|---|---|
| 201 | Copy created. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — a sending_access key cannot read or manage templates. Also returned when the team is suspended. |
| 404 | Template not found in your team. |
| 429 | More than 30 requests a minute to this endpoint from one key. Honour Retry-After. |
| 409 | More than 50 copies of this template already exist. Rename or delete some, then duplicate again. |
Delete a template#
/templates/:idDelete 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.
{
"success": true
}await poststack.templates.delete('tpl_2wx7c4v9b1n6m3k8q5z0p7ds');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
| Status | Meaning |
|---|---|
| 200 | Deleted. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — a sending_access key cannot read or manage templates. Also returned when the team is suspended. |
| 404 | Template not found in your team. |
| 429 | More than 30 requests a minute to this endpoint from one key. Honour Retry-After. |
List presets#
/templates/presetsThe built-in starting points: welcome, password-reset, invoice, newsletter, announcement, event-invitation, order-confirmation and feedback-request.
{
"data": [
{
"id": "welcome",
"name": "Welcome Email",
"subject": "Welcome to {{company_name}}",
"html": "<!DOCTYPE html>...",
"variables": ["company_name", "first_name", "dashboard_url", "unsubscribe_url"]
}
]
}// Returns the array directly.
const presets = await poststack.templates.getPresets();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
| Status | Meaning |
|---|---|
| 200 | The presets. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — a sending_access key cannot read or manage templates. Also returned when the team is suspended. |
Create a template from a preset#
/templates/presets/:presetId/useCreate an unpublished template at version 1 from a preset, named after it (e.g. 'Welcome Email'). Edit it like any other template.
HTTP/1.1 201 Created
{
"template": {
"id": 79,
"publicId": "tpl_5ty8u1i4o7p0a3s6d9f2g5hj",
"name": "Welcome Email",
"published": false,
"version": 1,
"...": "every other field, as in Create"
}
}const template = await poststack.templates.usePreset('welcome');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
| Status | Meaning |
|---|---|
| 201 | Template created. |
| 400 | presetId is not a preset-style id (lowercase slug). |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — a sending_access key cannot read or manage templates. Also returned when the team is suspended. |
| 404 | Preset not found — no preset has that id. |
| 429 | More than 30 requests a minute to this endpoint from one key. Honour Retry-After. |
| 409 | A 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:
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.
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.