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.
Create a template
Section titled “Create a template”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, setdisplayNameinstead (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 returns422 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) ormarkdown(optional). See Markdown bodies.type:email(default),layoutorpartial(optional). See Layouts and partials.localeVariants: per-localesubject/bodyoverrides keyed by locale tag, e.g.{"nl": {"subject": "…", "body": "…"}}(optional, up to 20). See Recipient locale and timezone.
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." } }Listing and fetching
Section titled “Listing and fetching”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/templatesreturns{ "data": [ … ] }with every template in the workspace, each with itscurrent_version.GET /api/v1/templates/{id}returns a single template. Pass thetpl_…id; an unknown or foreign id is a404.
The templating engine
Section titled “The templating engine”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 asmap,reduce,sortandslice) 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 %}.rangeis 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.
Markdown bodies
Section titled “Markdown bodies”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. Picktwigif you want shared headers and footers.
The unsubscribe link
Section titled “The unsubscribe link”{{ 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.
Layouts and partials
Section titled “Layouts and partials”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 typelayout).{% include "Footer" %}to pull in a shared partial (typepartial) 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.
Recipient locale and timezone
Section titled “Recipient locale and timezone”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
localepicks 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 carryingnl_NL,nl-NLornlall render it: tags are canonicalized before matching, and only the language part has to agree. - Unknown locales are rejected at authoring time. A
localeVariantskey that is not a real locale tag fails the create or version call with422 invalid_localerather than being stored and never matching. Alocaleon 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") }}(andformat_date/format_time) render in the resolved timezone, so the same UTC instant reads12 jun 2026, 14:30forEurope/Madridand12 Jun 2026, 08:30forAmerica/New_York. The plaindatefilter also honours the timezone. - Locale-aware money.
{{ order.total_cents / 100 |format_currency("EUR") }}formats the amount and symbol for the resolved locale.
Publish a new version
Section titled “Publish a new version”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.
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.
Roll back to an earlier version
Section titled “Roll back to an earlier version”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.
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.
Data contracts
Section titled “Data contracts”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”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, ornullif none.suggestion: a schema inferred from the version’ssample_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, itsrequirednames 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 astrue(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.
Pin a contract
Section titled “Pin a contract”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:
404if the template id is not in your workspace.409 template_has_no_versionif you pin a contract before authoring any version.422 invalid_schemaif the schema cannot be parsed. The message says what is wrong with it.
What a violation does on send
Section titled “What a violation does on send”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:
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.
Removing a contract
Section titled “Removing a contract”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.
Preview before you send
Section titled “Preview before you send”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 storedsample_datais 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.
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.
Datetimes and zones
Section titled “Datetimes and zones”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. InEurope/Amsterdamit reads09:00; inAmerica/New_York,03:00. - A datetime without one (
2026-09-23T09:00:00) is read as UTC, then converted the same way — so it reads11:00inEurope/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).
Names and titles
Section titled “Names and titles”A template has two labels, and they do different jobs:
nameis 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_nameis an optional readable title. Nothing composes by it, it need not be unique, and changing it affects nothing but what people read.labelin 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.
# 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.
Clone a template
Section titled “Clone a template”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).
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).
Send a message with a template
Section titled “Send a message with a template”To send, reference the template on POST /api/v1/messages by its templateId (the tpl_… id) and pass your render variables as data.
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.
Gotchas
Section titled “Gotchas”- Strict variables. A variable referenced in the template but missing from
datais a hardruntimeerror, not a blank. Always preview with representativedata(or keepsample_datacomplete). - Send renders the latest version. Publishing a new version changes what every subsequent send produces immediately. Preview a specific
versionfirst; 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_namehas no uniqueness rule and no template composes by it, so it is the field to edit freely;nameis 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
sandboxrender 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 stabletpl_…id. - Contracts are pinned per version.
PUT /templates/{id}/contractmints 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
PUToverwrites the live schema, but every version reports thedata_contractit pinned, and reverting to that version restores it. - Either spelling works on a request,
sampleDataorsample_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,localeVariantsordefaultLocaleand the new version keeps the current one’s. To clear one, send it:{}for the two maps,nullfor the locale. Publishing from a system that holds no translations therefore does not wipe them.