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://app.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— human label (required, max 255).slug— stable identifier, unique per workspace. Lowercase letters, digits and dashes only, e.g.order-confirmation(required, max 100).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://app.pufferpost.com/v1/templates \ -H "Authorization: Bearer key_live_…" \ -H "Content-Type: application/json" \ -d '{ "name": "Order confirmation", "slug": "order-confirmation", "subject": "Order {{ order.number }} confirmed", "body": "{% extends \"layout-base\" %}\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", "slug": "order-confirmation", "type": "email", "engine": "twig", "current_version": { "id": "tplv_01J9Z4M0VQ2C8F0R5T1X9A6E7H", "version_no": 1, "subject_source": "Order {{ order.number }} confirmed", "body_source": "{% extends \"layout-base\" %}…", "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. The slug works too, but the id never moves even if you rename the slug.
If the slug is already used in the workspace you get a 409 Conflict:
{ "error": { "code": "template_slug_taken", "message": "A template with slug \"order-confirmation\" already exists." } }Listing and fetching
GET /v1/templatesreturns{ "data": [ … ] }— 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,nl2br,striptags,round,replace,merge,keys,first,last,reverse. - Functions:
max,min,range,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 slug:
{% extends "layout-base" %}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 slug) and the email body can extend or include it.
Recipient locale and timezone
Date filters render in the recipient’s timezone, not the server’s — storage stays UTC, output is localised. You control this at send time with the locale and timezone fields on the message (see below); the renderer applies the resolved timezone to {{ … |date(…) }} and date(). So order.ships_at|date("j F Y") formats the same UTC instant differently for a recipient in Europe/Madrid vs America/New_York.
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://app.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 \"layout-base\" %}\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 \"layout-base\" %}…", "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 (both 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.
curl https://app.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 — 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:
slug— the new template’s slug (required, must be free in the workspace, same lowercase-dash rules).name— optional; defaults to the source template’s name.
curl https://app.pufferpost.com/v1/templates/tpl_01J9Z4M0V0K3QH6S2W7Y8B5N3D/clone \ -H "Authorization: Bearer key_live_…" \ -H "Content-Type: application/json" \ -d '{ "slug": "order-confirmation-b2b", "name": "Order confirmation (B2B)" }'Returns 201 Created with the new template (same shape as create). A taken slug is a 409 (template_slug_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 and pass your render variables as data. Use templateId (the tpl_… id, preferred and stable) or template (the slug).
curl https://app.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. Sending is always asynchronous (the response is an accepted message you can track via the message and event APIs); it is never rendered synchronously in the request path.
See the full send payload — attachments, cc, replyTo, metadata, unsubscribeGroup, 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. - Slugs are workspace-unique and lowercase-dashed.
Order_Confirmationis rejected; useorder-confirmation. A clash returns409 template_slug_taken. - Layouts/partials are workspace-scoped and referenced by slug.
{% extends %}/{% include %}resolve only against your own workspace’s templates — create the layout/partial before the email that uses it. - 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. - Prefer
templateIdovertemplate. Thetpl_…id is stable across renames; the slug can change.