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/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 {}).
Terminal window
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/templates returns { "data": [ … ] } with every template in the workspace, each with its current_version.
  • GET /v1/templates/{id} returns a single template. Pass the tpl_… id; an unknown or foreign id is a 404.

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 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. 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 locale picks 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") }} (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.

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.

Terminal window
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 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.
Terminal window
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).
Terminal window
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.

Terminal window
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 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 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 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.