Templates: Create, Version & Test
Manage versioned email templates with variable substitution
Templates are reusable email templates with placeholder variables. Instead of composing HTML in code every time, define a template once and send it hundreds of times with different variables.
Two Formats: HTML and Markdown
Every template has a format, chosen at creation and immutable afterwards:
html(default) — you provide the email HTML yourself. Everything on this page uses this format.emailmd— you provide markdown in amarkdownfield; the server renders it into responsive, email-safe HTML at save time and stores the markdown assource. Thehtmlfield of the response is the rendered output and cannot be set directly (sendinghtmlfor this format fails with 422). A statelessPOST /v1/templates/render-previewendpoint renders markdown without saving.
Markdown templates get their own guide — syntax, security model, end-to-end
flow: Markdown Templates. Responses on
this page omit the format field for brevity; templates created before
formats existed are html.
Create a Template
A template is a name plus a body. The request accepts name, format,
and one of html or markdown — nothing else.
A template has no subject. There is no subject field on a template, and
no text field you can set: the subject belongs to each send. Sending one in
this request has no effect — it is silently ignored, not stored.
cURL
curl -X POST https://api.tratto.email/v1/templates \
-H "Authorization: Bearer tratto_live_..." \
-H "Content-Type: application/json" \
-d '{
"name":"Welcome Email",
"html":"<h1>Welcome, {{firstName}}!</h1><p>Your code: {{code}}</p>"
}'Response:
{
"data": {
"id": "tmpl_abc123",
"name": "Welcome Email",
"html": "<h1>Welcome, {{firstName}}!</h1><p>Your code: {{code}}</p>",
"status": "draft",
"version": 1,
"createdAt": "2025-06-30T12:00:00Z",
"updatedAt": "2025-06-30T12:00:00Z"
}
}Publish before you send
A template is created with status: "draft", and status accepts draft or
published. Only a published template can be used to send. Publishing is
a step, not a label: the template has to be published, and any other status
is refused.
Publish it like any other field update:
cURL
curl -X PATCH https://api.tratto.email/v1/templates/tmpl_abc123 \
-H "Authorization: Bearer tratto_live_..." \
-H "Content-Type: application/json" \
-d '{"status":"published"}'status is also a filter on the list endpoint:
GET /v1/templates?status=published.
Where the gate applies
| What you do with a draft template | What happens |
|---|---|
POST /v1/campaigns with templateId | 422 TEMPLATE_NOT_PUBLISHED |
PATCH /v1/campaigns/:id swapping in templateId | 422 TEMPLATE_NOT_PUBLISHED |
POST /v1/flows/:id/activate with a send_email step using it | 422 TEMPLATE_NOT_PUBLISHED (404 if the template does not exist) |
POST /v1/emails with templateId | accepted, then the email is marked failed and never leaves |
POST /v1/templates/:id/test-send | allowed, see below |
The campaign and flow errors look like this:
{
"error": {
"code": "TEMPLATE_NOT_PUBLISHED",
"message": "Template 'tmpl_abc123' is not published and cannot be used to send.",
"docs": "https://docs.tratto.email/en/docs/error-codes",
"suggestion": "Publish the template first: PATCH /v1/templates/:id with { \"status\": \"published\" }, or use the Publish button in the template editor."
}
}On flow activation the message names the step too:
Template 'tmpl_abc123' on step 's0' is not published and cannot be used to send.
POST /v1/emails does not read the template when it accepts the request, so it
still answers 202. The gate is applied by the delivery worker: the email ends
up with status: "failed" and nothing is sent. With API 0.29.0 or later
GET /v1/emails/{id} returns failureReason: "template_not_published", and
webhooks subscribed to failed receive a
failed event whose data.error is
Template 'tmpl_abc123' is not published and cannot be used to send. The
email is still lost, so do not send with a draft. The same happens if a
template is unpublished after an email has been queued with it.
Test sends are exempt. POST /v1/templates/:id/test-send works on a
draft template. Checking a draft in a real inbox is the whole point, and it
is the only way to review one before publishing it. A campaign test send is
unaffected either way: it sends the campaign's own copy of the HTML and never
resolves a template.
The gate is not retroactive. A campaign copies the template's HTML into itself at creation, so campaigns created before this was introduced keep their content and still send. The gate applies when a campaign is created or has its template swapped, when a flow is activated, and at delivery time for emails that resolve a template.
Send Using a Template
The template supplies the body, and it has to be published (see
Publish before you send). subject is required
on the send and has to be passed every time: omit it and the request fails
with 422.
cURL
curl -X POST https://api.tratto.email/v1/emails \
-H "Authorization: Bearer tratto_live_..." \
-H "Content-Type: application/json" \
-d '{
"from":"hello@yourdomain.com",
"to":"user@example.com",
"subject":"Welcome, {{firstName}}!",
"templateId":"tmpl_abc123",
"variables":{
"firstName":"Alice",
"code":"SECRET123"
}
}'Renders to:
Subject: Welcome, Alice!
HTML: <h1>Welcome, Alice!</h1><p>Your code: SECRET123</p>The subject is rendered with the same variables as the body, so
{{firstName}} works in both. A variable you don't pass becomes an empty
string in the subject too — see Always Pass Every Variable below.
An html template has no text part. A Markdown template pins a rendered
text part at save time, and that one is sent as text/plain alongside the
HTML.
Update a Template
Update a template. This creates a new version, leaving previous versions intact.
cURL
curl -X PATCH https://api.tratto.email/v1/templates/tmpl_abc123 \
-H "Authorization: Bearer tratto_live_..." \
-H "Content-Type: application/json" \
-d '{
"html":"<h1>Welcome back, {{firstName}}!</h1><p>Code: {{code}}</p>"
}'Response: the full template, with version incremented.
{
"data": {
"id": "tmpl_abc123",
"name": "Welcome Email",
"html": "<h1>Welcome back, {{firstName}}!</h1><p>Code: {{code}}</p>",
"status": "draft",
"version": 2,
"createdAt": "2025-06-30T12:00:00Z",
"updatedAt": "2025-06-30T12:05:00Z"
}
}The version only bumps when the content actually changes — a PATCH that sets
the same HTML leaves it alone. Versions have no status of their own, and
bumping the version does not touch status on the template.
For Markdown templates, update the markdown field instead — every change
re-renders the HTML and increments the version the same way. html and
markdown are mutually exclusive in a PATCH, and each one is only accepted by
a template of the matching format (422 otherwise).
Unpublishing a template an active flow uses
Setting status back to draft answers 409 when an active flow is using
that template:
{
"error": {
"code": "CONFLICT",
"message": "This template is used by flow 'Welcome' (active). Unpublishing it would break the next send.",
"suggestion": "Pause the flow, or point that step at another template first."
}
}The reason is the gate described above: an unpublished template is refused at delivery, so unpublishing it while a flow uses it would break that flow on its next step. A draft flow blocks nothing, because it does not send.
Version History
List the versions of a template, newest first (up to 20). Each entry carries only the version number and when it was saved:
cURL
curl https://api.tratto.email/v1/templates/tmpl_abc123/versions \
-H "Authorization: Bearer tratto_live_..."Response:
{
"data": [
{ "version": 2, "savedAt": "2025-06-30T12:05:00Z" },
{ "version": 1, "savedAt": "2025-06-30T12:00:00Z" }
]
}Get One Version
To read the content of a past version — the only way to recover it — fetch it by number:
cURL
curl https://api.tratto.email/v1/templates/tmpl_abc123/versions/1 \
-H "Authorization: Bearer tratto_live_..."Response:
{
"data": {
"version": 1,
"html": "<h1>Welcome, {{firstName}}!</h1><p>Your code: {{code}}</p>",
"savedAt": "2025-06-30T12:00:00Z"
}
}On a Markdown template the response also carries source: the markdown that
produced that version's HTML.
Test Send
Send a test email using the template to verify it looks correct.
cURL
curl -X POST https://api.tratto.email/v1/templates/tmpl_abc123/test-send \
-H "Authorization: Bearer tratto_live_..." \
-H "Content-Type: application/json" \
-d '{
"to":"your-email@example.com",
"variables":{
"firstName":"Alice",
"code":"TEST123"
}
}'A test email is sent immediately to your address so you can review it in your inbox.
If Tratto cannot queue the test email, the API returns 503 SERVICE_UNAVAILABLE
with a Retry-After header: nothing was sent and no email was created, so you
can retry. See Error Codes.
Its subject is [Test] followed by the template name. A test send has no
contact, so the recipient variables you don't pass get preview values: email
is the test address, firstName is Preview, lastName is Recipient, and
unsubscribe_url is a preview link that unsubscribes nobody. Any other
variable you leave out renders empty, as on a real send.
Free, with a daily cap
Test sends are free: they do not count against the
monthly quota. Each call sends to one recipient. With
API 0.24.0 or later, a workspace can make 20 test sends per day on
Free, 100 on Starter and Growth and 200 on Scale, template and campaign test sends together, and the count resets at midnight
UTC. Past the cap the API answers 429 QUOTA_EXCEEDED and nothing is sent: do
not retry before the reset. A call refused with 404 or 422 does not use
the cap; an accepted call does, even if it then answers 503, because the cap
is taken before the email is handed to the queue.
{
"error": {
"code": "QUOTA_EXCEEDED",
"message": "Test send daily limit of 20 per workspace reached.",
"suggestion": "The limit resets at midnight UTC. Test sends are free and do not count against your monthly quota. Starter raises the test-send limit to 100 a day. The account owner can upgrade at https://app.tratto.email/settings/billing.",
"docs": "https://docs.tratto.email/en/docs/error-codes"
}
}List Templates
List the templates of your workspace, newest first, one page at a time.
Requires templates:read.
cURL
curl "https://api.tratto.email/v1/templates?status=published&limit=50" \
-H "Authorization: Bearer tratto_live_..."Query parameters:
| Parameter | Default | What it does |
|---|---|---|
limit | 50 | Results per page, 1 to 100 |
after | none | The nextCursor of the previous page |
status | none | draft or published. Any other value answers 422 |
include | none | html adds each template's html to the rows. The only accepted value |
Response:
{
"data": [
{
"id": "tmpl_abc123",
"name": "Welcome",
"status": "published",
"format": "html",
"version": 2,
"createdAt": "2025-06-30T12:00:00Z",
"updatedAt": "2025-07-02T09:30:00Z"
}
],
"pagination": {
"hasMore": true,
"nextCursor": "tmpl_abc123"
}
}The rows carry no html unless you ask for it with include=html: it is the
largest field of a template and most lists do not need it. For everything else
about one template, use Get a Template.
The list stops at one page. Without after you get the 50 newest
templates and nothing tells you there are more except pagination.hasMore. To
read them all, pass nextCursor as after until hasMore is false:
# First page
curl "https://api.tratto.email/v1/templates?limit=100" \
-H "Authorization: Bearer tratto_live_..."
# Next page: after = the nextCursor you just received
curl "https://api.tratto.email/v1/templates?limit=100&after=tmpl_abc123" \
-H "Authorization: Bearer tratto_live_..."On the last page hasMore is false and nextCursor is null. A cursor that
does not match an existing template is ignored and the list starts again from
the first page. Keep the same status on every page of one walk.
Get a Template
Retrieve a specific template.
cURL
curl https://api.tratto.email/v1/templates/tmpl_abc123 \
-H "Authorization: Bearer tratto_live_..."Delete a Template
Permanently delete a template (cannot be undone).
cURL
curl -X DELETE https://api.tratto.email/v1/templates/tmpl_abc123 \
-H "Authorization: Bearer tratto_live_..."Deleting a template cannot be undone: the full version history goes with it, including the HTML and markdown of every past revision.
A template in use cannot be deleted. If a flow references it, or a campaign
that has not finished yet (scheduled, sending, paused) uses it, the request
answers 409 and names what is using it:
{
"error": {
"code": "CONFLICT",
"message": "This template is used by flow 'Welcome' (active).",
"suggestion": "Remove it from those first, or leave the template in place."
}
}A campaign that is already completed does not block deletion: it will not send
again.
Before 29 September 2026 the deletion went through anyway, and the flow failed on
its next send: the email was marked failed while the enrolment moved on, so the
recipient got nothing and no error showed up in the dashboard.
Template Variable Syntax
Use double curly braces {{variableName}} for placeholders — surrounding
whitespace is allowed, so {{ firstName }} works too. Variables are
case-sensitive. There are no conditionals and no loops: this is substitution,
not a templating language.
Template:
HTML: <p>Hello {{firstName}}, your email is {{email}}</p>Sending:
{
"variables": {
"firstName": "Alice",
"email": "alice@example.com"
}
}Rendered:
HTML: <p>Hello Alice, your email is alice@example.com</p>Substitution applies to the subject and to the HTML and text bodies.
Best Practices
1. Use Semantic Variable Names
✅ {{firstName}}, {{orderID}}, {{confirmationCode}}
❌ {{x}}, {{var1}}, {{v}}
2. Always Pass Every Variable
A missing variable does not fail the send: the placeholder is replaced with an empty string. This template
<p>Hello {{firstName}}, welcome to {{companyName}}!</p>sent without firstName or companyName arrives as Hello , welcome to ! —
the punctuation is left stranded, but the recipient never sees the name of
your variable.
This applies to the subject and the body, in HTML and in Markdown, on
transactional sends and campaigns alike. null substitutes to empty too: the
strings null and undefined never reach an inbox.
None of which is an invitation to pass fewer variables — quite the opposite.
A sentence with a hole in it is still a wrong sentence, and now nothing
flags it: not at send time, not before. With the placeholder left visible, a
typo like {{firstname}} for {{firstName}} at least showed up in the
preview; with empty substitution it vanishes without a trace.
The automatic check exists for campaigns only, where Tratto knows the set
of available variables and can flag anything outside it while the campaign is
still a draft. The set is listed in
Campaign Variables. For a template it
isn't possible: the variables come from whoever calls POST /v1/emails, so
there is no set to check against and every check would be a guess.
On a template, then, the only defence is passing every placeholder the template uses on every send.
3. Use Templates for Bulk Sends
Don't compose HTML per email. Use templates and variables for consistency and reusability.
4. Test Before You Use It
Always test-send a template before you send it to anyone, to catch rendering
issues. A test-send is the only send a draft template can do, so it is also
the only review step there is before you publish.
Next Steps
- Send with a template? See Send Email
- Write templates in markdown? See Markdown Templates
- Use templates in campaigns? Go to Campaigns
- Use templates in flows? See Flows
Edit this page on GitHub
Last updated on