Skip to content

Templates

Templates let you author email once, as sandboxed Twig with {{ variables }}, store it under a stable id, and send personalised messages by passing JSON data. Edit the copy, layout or styling and publish a new version; sending keeps working with no code change or redeploy.

This guide covers creating a template, versioning it, previewing it against sample data, cloning it, and sending a message that renders it. For the full field reference see the API reference.

All requests use the base URL https://pufferpost.com/api/v1 and a bearer API key. Template management needs the templates:manage scope; reading and previewing need templates:read.

POST /api/v1/templates authors a template and its first version in one call.

Body fields:

  • name: the template’s identifier, unique per workspace (required, max 255). It is what you reference in {% extends %} / {% include %} (see Layouts and partials). For a readable title that nothing composes by, set displayName instead (see Names and titles).
  • subject: the subject line source, rendered as plain text. Required for an email; optional for a layout or partial, which has no subject of its own and falls back to the template’s name. Sending an email template without one returns 422 subject_required.
  • body: the HTML body source; auto-escaped Twig (required).
  • sampleData: a JSON object of example variables used by preview and the editor (optional, defaults to {}).
  • engine: twig (default) or markdown (optional). See Markdown bodies.
  • type: email (default), layout or partial (optional). See Layouts and partials.
  • localeVariants: per-locale subject/body overrides keyed by locale tag, e.g. {"nl": {"subject": "…", "body": "…"}} (optional, up to 20). See Recipient locale and timezone.
Terminal window
curl https://pufferpost.com/api/v1/templates \
-H "Authorization: Bearer key_live_…" \
-H "Content-Type: application/json" \
-d '{
"name": "Order confirmation",
"subject": "Order {{ order.number }} confirmed",
"body": "{% extends \"Base layout\" %}\n{% block content %}\n <h1>Thanks, {{ customer.first_name }}!</h1>\n <p>Your order <strong>{{ order.number }}</strong> totalling {{ order.total }} is on its way.</p>\n <p>Estimated delivery: {{ order.ships_at|date(\"j F Y\") }}.</p>\n{% endblock %}",
"sampleData": {
"customer": { "first_name": "Maria" },
"order": { "number": "A-10423", "total": "€49.90", "ships_at": "2026-06-12" }
}
}'

A 201 Created returns the template with its current version embedded:

{
"id": "tpl_01J9Z4M0V0K3QH6S2W7Y8B5N3D",
"name": "Order confirmation",
"type": "email",
"engine": "twig",
"current_version": {
"id": "tplv_01J9Z4M0VQ2C8F0R5T1X9A6E7H",
"version_no": 1,
"subject_source": "Order {{ order.number }} confirmed",
"body_source": "{% extends \"Base layout\" %}…",
"sample_data": { "customer": { "first_name": "Maria" }, "order": { "number": "A-10423", "total": "€49.90", "ships_at": "2026-06-12" } },
"created_at": "2026-06-09T10:21:44+00:00"
},
"created_at": "2026-06-09T10:21:44+00:00"
}

Keep the id (tpl_…): it is the stable handle you reference when sending, unchanged even if you rename the template.

If a template with that name already exists in the workspace you get a 409 Conflict:

{ "error": { "code": "template_name_taken", "message": "A template named \"Order confirmation\" already exists." } }

GET /api/v1/templates accepts an optional name filter, matching exactly. Names are unique per workspace, so it returns at most one template, and a name nobody has returns an empty list rather than everything.

  • GET /api/v1/templates returns { "data": [ … ] } with every template in the workspace, each with its current_version.
  • GET /api/v1/templates/{id} returns a single template. Pass the tpl_… id; an unknown or foreign id is a 404.

Templates render with Twig running in a sandbox: no filesystem access, no PHP calls, no object methods. Only an allow-list of tags, filters and functions is available, and it is exactly this:

  • Tags: block, embed, extends, for, if, include, set, use.
  • Filters: abs, capitalize, date, default, e, escape, first, format, format_currency, format_date, format_datetime, format_number, format_time, join, keys, last, length, lower, merge, nl2br, number_format, replace, reverse, round, safe_url, split, striptags, title, trim, upper.
  • Functions: date, max, min.

Anything outside those three lines is rejected at render time with Filter "x" is not allowed (or the tag/function equivalent), which a preview shows you before a send ever uses it. Two absences are worth calling out because they come up often:

  • filter (and the other iterable-transform filters such as map, reduce, sort and slice) is not available. Shape the list in your own code and send it ready to render, or drive the output with {% if %} inside the {% for %}.
  • range is excluded deliberately: combined with {% for %} it is an unbounded loop source, so it is the one thing a template could use to burn the render worker’s CPU. Looping over your own data is unaffected.

The subject renders as plain text; the body auto-escapes HTML. Rendering uses strict variables: referencing a variable you did not supply is a hard error (a structured 422, see Preview), never a silent blank. This is by design. A missing {{ order.total }} should fail loudly, not ship a broken email.

Set "engine": "markdown" at create time to author the body as GitHub-flavored Markdown with the same {{ variables }} and logic as the Twig engine. The body is interpolated first, then converted to HTML; raw HTML in the source or in your data is escaped and unsafe links are dropped, so the no-injection guarantee is the same.

  • The renderer produces HTML. A plain-text alternative is derived from that HTML automatically, so the message still goes out with both parts.
  • Markdown bodies cannot compose layouts or partials. {% extends %} / {% include %} reuse is Twig-engine only. Pick twig if you want shared headers and footers.

{{ unsubscribe_url }} is a reserved variable the platform fills in: it resolves to the hosted one-click unsubscribe URL for that recipient, signed against the send’s unsubscribeGroup. Supplying your own unsubscribe_url in data does not override it, and it is the same URL the List-Unsubscribe header carries, so a visible link and the header can never disagree.

Previews fill it with a placeholder URL, since a preview has no recipient to sign for.

The seeded Footer section already uses it, so a template composing that footer needs nothing further. See Suppressions & unsubscribe.

A template’s body may compose other templates from the same workspace by their name:

  • {% extends "Base layout" %} to inherit a shared layout (a template of type layout).
  • {% include "Footer" %} to pull in a shared partial (type partial) such as a header or footer.

Composition is resolved only against your own workspace’s templates; layouts and partials never cross a tenant boundary. Create the layout/partial first (it is just another template, referenced by its name) and the email body can extend or include it.

A shared header or footer is just a template you create through the API. Pass "type": "partial" (or "layout") on POST /api/v1/templates and leave subject out; a section has no subject of its own, so its name stands in. Composition resolves by name against every template in your workspace whatever its type, so the type is about how the template is grouped and offered in the dashboard rather than whether it can be composed.

Because the include key is the template’s name, renaming a template changes how others reference it. Renaming one that other templates include is refused with 409 template_name_referenced, and the error names the templates that would break, so update their {% extends %} / {% include %} calls first. See Names and titles.

Two things happen when you send a template that composes others:

  • A referenced name that does not exist in your workspace is rejected up front with 422 section_not_found, before the send is accepted or counted.
  • The composed bodies are pinned onto the message at that moment. Editing a shared footer afterwards changes future sends only; a message already accepted renders what was pinned.

Templates render in the recipient’s locale and timezone. Stored timestamps stay UTC; only the output is localised (see the localization section of Sending email for the full resolution order).

  • Localized variants. A template version can carry per-locale subject/body overrides; the resolved locale picks the matching variant (exact tag, then language), falling back to the base content, never to a blank. So with a single "nl" variant, a send carrying nl_NL, nl-NL or nl all render it: tags are canonicalized before matching, and only the language part has to agree.
  • Unknown locales are rejected at authoring time. A localeVariants key that is not a real locale tag fails the create or version call with 422 invalid_locale rather than being stored and never matching. A locale on a send that has no variant simply falls back to the base content.
  • Recipient-local dates. {{ order.ships_at|format_datetime(pattern="d MMM yyyy, HH:mm") }} (and format_date / format_time) render in the resolved timezone, so the same UTC instant reads 12 jun 2026, 14:30 for Europe/Madrid and 12 Jun 2026, 08:30 for America/New_York. The plain date filter also honours the timezone.
  • Locale-aware money. {{ order.total_cents / 100 |format_currency("EUR") }} formats the amount and symbol for the resolved locale.

POST /api/v1/templates/{id}/versions mints a new version. The new version becomes the one sends render; older versions stay for history. There is no redeploy: the next send picks it up.

Body: subject and body (both required), plus optional sampleData, localeVariants and defaultLocale.

Leave any of the three out and the new version inherits the current one’s, so publishing from a system that does not hold your translations will not wipe them. To clear one deliberately, send it explicitly: {} for sampleData or localeVariants, null for defaultLocale.

Terminal window
curl https://pufferpost.com/api/v1/templates/tpl_01J9Z4M0V0K3QH6S2W7Y8B5N3D/versions \
-H "Authorization: Bearer key_live_…" \
-H "Content-Type: application/json" \
-d '{
"subject": "Your order {{ order.number }} is confirmed",
"body": "{% extends \"Base layout\" %}\n{% block content %}<h1>Thanks, {{ customer.first_name }}!</h1>…{% endblock %}",
"sampleData": { "customer": { "first_name": "Maria" }, "order": { "number": "A-10423", "total": "€49.90", "ships_at": "2026-06-12" } }
}'

201 Created returns the new version:

{
"id": "tplv_01J9Z5R3C7M2D8K0P4V6X1B9F2",
"version_no": 2,
"subject_source": "Your order {{ order.number }} is confirmed",
"body_source": "{% extends \"Base layout\" %}…",
"sample_data": { "customer": { "first_name": "Maria" }, "order": { "number": "A-10423" } },
"data_contract": null,
"created_at": "2026-06-09T11:02:10+00:00"
}

GET /api/v1/templates/{id}/versions lists the history, newest first, each version carrying the same fields, including the data_contract pinned on it.

POST /api/v1/templates/{id}/versions/{version_no}/revert re-publishes an earlier version as a new one. History is append-only: nothing is deleted, and the restored content comes back as the next version number.

Terminal window
curl -X POST https://pufferpost.com/api/v1/templates/tpl_01J9.../versions/2/revert \
-H "Authorization: Bearer key_live_…"

201 Created returns the newly published version. A revert restores that version whole: its subject, body, sample data, localized variants, authored default locale and the data contract it pinned, which becomes the template’s live contract again. An unknown version number is a 404.

A template can pin a JSON Schema describing the data payload it expects. Once pinned, every send is validated against it and a payload that does not match is refused with a 422 naming each problem, instead of failing later at render time or quietly producing a broken email.

This is optional. A template with no contract skips validation entirely, so nothing changes for templates you never pin one on.

See the current contract, and a suggested one

Section titled “See the current contract, and a suggested one”
Terminal window
curl https://pufferpost.com/api/v1/templates/tpl_01J9.../contract \
-H "Authorization: Bearer key_live_…"
{
"contract": null,
"suggestion": {
"type": "object",
"properties": {
"customer": { "type": "object", "properties": { "first_name": { "type": "string" } } },
"order": { "type": "object", "properties": { "number": { "type": "string" } } }
}
},
"bindable": [{ "path": "customer.first_name", "type": "string" }]
}
  • contract: what is pinned today, or null if none.
  • suggestion: a schema inferred from the version’s sample_data. Adopt it as-is or edit it first.
  • bindable: the flattened field paths, for editor autocomplete.

Two things to know about the suggestion, because it is inferred from a single sample:

  • It cannot invent required. One sample cannot prove a field is always present, so inference marks nothing required. When a contract is already pinned, its required names are carried into the suggestion for the fields the sample still has, so adopting the suggestion never drops a constraint you had. Fields the sample no longer contains are left behind: a required name with no matching property would make the draft reject its own sample.
  • Empty values stay open. A null, and an empty [] or {} in the sample, are suggested as true (accept anything) rather than guessed at. JSON round-trips make an empty array and an empty object indistinguishable, so committing to "type": "array" would refuse every send that fills the field with an object.
Terminal window
curl -X PUT https://pufferpost.com/api/v1/templates/tpl_01J9.../contract \
-H "Authorization: Bearer key_live_…" \
-H "Content-Type: application/json" \
-d '{
"schema": {
"type": "object",
"required": ["customer", "order"],
"properties": {
"customer": {
"type": "object",
"required": ["first_name"],
"properties": { "first_name": { "type": "string" } }
},
"order": {
"type": "object",
"required": ["number", "total"],
"properties": {
"number": { "type": "string" },
"total": { "type": "number" }
}
}
}
}
}'
{ "contract": { "type": "object", "required": ["customer", "order"], "…": "…" }, "version": 3 }

Setting a contract mints a new version. The response tells you which one. The new version carries the same subject, body and sample data as the previous latest, plus the contract, so it takes effect on the very next send with no further call. Every version you publish afterwards pins whatever contract the template has at that moment, so it carries forward on its own.

Failure cases:

  • 404 if the template id is not in your workspace.
  • 409 template_has_no_version if you pin a contract before authoring any version.
  • 422 invalid_schema if the schema cannot be parsed. The message says what is wrong with it.

It rejects the send. Nothing is queued, nothing is counted against your quota:

{
"error": {
"code": "invalid_payload",
"message": "Payload does not match the template data contract: /order/total: The data (string) must match the type: number; /customer/first_name: The required properties ([first_name]) are missing",
"request_id": "req_01J9…"
}
}

Every violation is listed in one response, not just the first, so a payload with several problems is fixable in a single pass.

One more code to know: if the pinned schema itself turns out to be unusable at send time, the send is refused with 422 invalid_contract rather than a 500. Fix the contract on the template; the payload is not at fault.

Both {} and true are valid JSON Schema for “accept anything”, and either one is stored and returned exactly as you pushed it, so reading a contract back and pushing it again leaves it unchanged.

The same ambiguity exists in the payload you send, and the contract settles it there too. {} and [] are the same value once JSON is decoded, so an empty value in data is read as an object unless the contract types that field as array. {"invoice": {}} therefore satisfies "invoice": {"type": "object"}, and {"lines": []} still satisfies "lines": {"type": "array"}.

A contract push replaces the previous schema

Section titled “A contract push replaces the previous schema”

PUT overwrites the live contract in place. The version it mints keeps a snapshot, so the schema you replaced is still readable:

Terminal window
curl https://pufferpost.com/api/v1/templates/tpl_01J9.../versions \
-H "Authorization: Bearer key_live_…"

Each version carries the data_contract pinned on it, so the one before a bad push is the data_contract of the version before it. POST /versions/{version_no}/revert puts that version, and its contract, back as the current one.

Pin a permissive schema (for example {"type": "object"}) to stop constraining payloads. Validation always runs against the schema pinned on the version being sent, so the change takes effect from the version the PUT mints onward.

POST /api/v1/templates/{id}/preview renders a template version and returns the HTML, without sending. Use it in CI or an editor to catch a broken variable or syntax error before it reaches a recipient.

Body (all optional):

  • data: render variables. When omitted, the version’s stored sample_data is used.
  • version: the version number to preview (e.g. 1). When omitted, the latest version renders.
  • locale: preview a specific localized variant. When omitted, the workspace’s default locale.
  • timezone: the IANA zone dates render in, e.g. Europe/Amsterdam. When omitted, the workspace’s default zone, which is also what a send uses when the recipient has no preference of their own. Pass it to rehearse another recipient’s zone.
Terminal window
curl https://pufferpost.com/api/v1/templates/tpl_01J9Z4M0V0K3QH6S2W7Y8B5N3D/preview \
-H "Authorization: Bearer key_live_…" \
-H "Content-Type: application/json" \
-d '{
"data": {
"customer": { "first_name": "Jonas" },
"order": { "number": "A-99001", "total": "€12.00", "ships_at": "2026-06-15" }
}
}'

A 200 OK returns the rendered subject and HTML:

{
"subject": "Your order A-99001 is confirmed",
"html": "<!doctype html><html>… <h1>Thanks, Jonas!</h1> …</html>",
"locale": "en",
"timezone": "Europe/Amsterdam"
}

The response reports the locale and timezone it used, so a preview can be lined up against a real send instead of guessed at.

A preview renders dates with the same locale and zone a send would use, so what you see is what goes out. Two rules decide the hour:

  • A datetime with an offset (2026-09-23T09:00:00+02:00) is an exact instant, converted into the render zone. In Europe/Amsterdam it reads 09:00; in America/New_York, 03:00.
  • A datetime without one (2026-09-23T09:00:00) is read as UTC, then converted the same way — so it reads 11:00 in Europe/Amsterdam. If you mean a wall-clock time in your own zone, send the offset, or pass the zone to the filter: {{ start|format_datetime(timezone="Europe/Amsterdam", pattern="EEEE d MMMM HH:mm") }}.

Sending an offset is the recommendation: it is unambiguous whatever the workspace, recipient or filter does.

A render failure returns a structured 422 with the kind of failure plus the source line, so you know exactly what to fix:

{
"error": {
"code": "render_failed",
"message": "Variable \"customer.first_name\" does not exist.",
"kind": "runtime",
"line": 2
}
}

kind is one of syntax (the Twig won’t parse), runtime (a missing variable or bad call, strict-variables territory), or sandbox (you used a tag/filter/function outside the allow-list).

A template has two labels, and they do different jobs:

  • name is the identifier. It is unique within the workspace and it is what other templates {% extends %} / {% include %} this one by. Treat it as a handle you settle on once.
  • display_name is an optional readable title. Nothing composes by it, it need not be unique, and changing it affects nothing but what people read. label in the response is the one to render: the title when there is one, otherwise the name.

PATCH /api/v1/templates/{id} edits either. Bodies and subjects live on versions, so neither publishes anything or mints a version, and the tpl_… id you send by always keeps working.

Terminal window
# Give it a readable title (the safe, everyday change)
curl -X PATCH https://pufferpost.com/api/v1/templates/tpl_01J9Z4M0V0K3QH6S2W7Y8B5N3D \
-H "Authorization: Bearer key_live_…" \
-H "Content-Type: application/json" \
-d '{ "displayName": "Order confirmation (EU)" }'

Send "displayName": null to clear the title; the name is shown again. Omit the field and the title is left as it is, so a rename never wipes it.

Renaming is the same call with name, and it can be refused — two conflicts, both 409:

  • template_name_taken: another template in the workspace already holds that name.
  • template_name_referenced: other templates compose this one by its current name, so renaming it would break them. The message names them; update those bodies first.

Because of that second case, prefer a displayName when what you want is a nicer label. Rename only when you actually want the include key to change.

You can also rename a template in the dashboard, on the template editor page: the name at the top of the left sidebar is editable and saves on its own.

POST /api/v1/templates/{id}/clone copies a template’s current version into a brand-new template. Handy to fork a starter or branch a variant.

Body:

  • name: the new template’s name (required, must be free in the workspace).
Terminal window
curl https://pufferpost.com/api/v1/templates/tpl_01J9Z4M0V0K3QH6S2W7Y8B5N3D/clone \
-H "Authorization: Bearer key_live_…" \
-H "Content-Type: application/json" \
-d '{ "name": "Order confirmation (B2B)" }'

Returns 201 Created with the new template (same shape as create). A taken name is a 409 (template_name_taken); a source template with no version yet is a 422 (template_has_no_version).

To send, reference the template on POST /api/v1/messages by its templateId (the tpl_… id) and pass your render variables as data.

Terminal window
curl https://pufferpost.com/api/v1/messages \
-H "Authorization: Bearer key_live_…" \
-H "Content-Type: application/json" \
-d '{
"from": "orders@acme.com",
"to": "maria@example.com",
"templateId": "tpl_01J9Z4M0V0K3QH6S2W7Y8B5N3D",
"data": {
"customer": { "first_name": "Maria" },
"order": { "number": "A-10423", "total": "€49.90", "ships_at": "2026-06-12" }
},
"locale": "es",
"timezone": "Europe/Madrid"
}'

The send renders the template’s latest version against data. The optional locale and timezone drive recipient-local rendering (date filters in the body format in Europe/Madrid here, while the stored instant stays UTC) and are remembered on the recipient’s contact for next time. Sending is always asynchronous: the response is an accepted message you can track via the message and event APIs, never a synchronous render in the request path.

See the full send payload (attachments, cc, replyTo, metadata, unsubscribeGroup, locale/timezone resolution, idempotency) in Sending email and the API reference.

  • Strict variables. A variable referenced in the template but missing from data is a hard runtime error, not a blank. Always preview with representative data (or keep sample_data complete).
  • Send renders the latest version. Publishing a new version changes what every subsequent send produces immediately. Preview a specific version first; clone if you need an isolated variant.
  • Names are workspace-unique. Creating, cloning or renaming to a name already in the workspace returns 409 template_name_taken.
  • Renaming publishes nothing. The name and the title live on the template, not on a version, so PATCH /templates/{id} leaves the history and what a send renders exactly as they were.
  • A title is not an identifier. display_name has no uniqueness rule and no template composes by it, so it is the field to edit freely; name is the one to leave alone.
  • Layouts/partials are workspace-scoped and referenced by name. {% extends %} / {% include %} resolve only against your own workspace’s templates. Create the layout/partial before the email that uses it, and remember that renaming a template changes the name others include it by.
  • Sandbox allow-list. Filters/tags/functions outside the list raise a sandbox render error. There is no filesystem, no PHP, no method calls; only the listed building blocks.
  • Send by templateId. Reference a template on a send with its stable tpl_… id.
  • Contracts are pinned per version. PUT /templates/{id}/contract mints a version carrying the schema, so it is live on the next send. Versions you publish afterwards carry the contract forward on their own.
  • A replaced contract is not lost. A PUT overwrites the live schema, but every version reports the data_contract it pinned, and reverting to that version restores it.
  • Either spelling works on a request, sampleData or sample_data; responses are always snake_case. An unrecognised key is still ignored rather than rejected.
  • A new version inherits what you leave out. Omit sampleData, localeVariants or defaultLocale and the new version keeps the current one’s. To clear one, send it: {} for the two maps, null for the locale. Publishing from a system that holds no translations therefore does not wipe them.