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/v1 and a bearer API key. Template management needs the templates:manage scope; reading and previewing need templates:read.
Create a template
POST /v1/templates authors a template and its first version in one call.
Body fields:
name: the template’s human handle, unique per workspace (required, max 255). It is what you reference in{% extends %}/{% include %}(see Layouts and partials).subject: the subject line source, rendered as plain text (required).body: the HTML body source; auto-escaped Twig (required).sample_data: a JSON object of example variables used by preview and the editor (optional, defaults to{}).
curl https://pufferpost.com/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 %}", "sample_data": { "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
GET /v1/templatesreturns{ "data": [ … ] }with every template in the workspace, each with itscurrent_version.GET /v1/templates/{id}returns a single template. Pass thetpl_…id; an unknown or foreign id is a404.
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:
- Tags:
if,for,set,include,extends,block,use,embed. - Filters include
escape/e,default,upper,lower,capitalize,title,trim,length,join,split,date,number_format,format_number,format_currency,format_date,format_datetime,format_time,format,nl2br,striptags,round,replace,merge,keys,first,last,reverse. - Functions:
max,min,date.
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.
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. Because the include key is the template’s name, renaming a template changes how others reference it: update the {% extends %} / {% include %} calls that point at it.
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. - 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
POST /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, body (both required), and optional sample_data.
curl https://pufferpost.com/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 %}", "sample_data": { "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" } }, "created_at": "2026-06-09T11:02:10+00:00"}Preview before you send
POST /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.
curl https://pufferpost.com/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>"}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).
Clone a template
POST /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/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
To send, reference the template on POST /v1/messages by its templateId (the tpl_… id) and pass your render variables as data.
curl https://pufferpost.com/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
- 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 or cloning to a name already in the workspace returns
409 template_name_taken. - 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.