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://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 {}).
Terminal window
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/templates returns { "data": [ … ] } — 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, 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 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 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.

Terminal window
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 stored sample_data is used.
  • version — the version number to preview (e.g. 1). When omitted, the latest version renders.
Terminal window
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.
Terminal window
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).

Terminal window
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 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.
  • Slugs are workspace-unique and lowercase-dashed. Order_Confirmation is rejected; use order-confirmation. A clash returns 409 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 sandbox render error. There is no filesystem, no PHP, no method calls — only the listed building blocks.
  • Prefer templateId over template. The tpl_… id is stable across renames; the slug can change.