Markdown Templates
Write email templates in markdown and let Tratto render responsive, email-safe HTML for you.
Tratto templates come in two formats: HTML (format: "html", you provide
the HTML) and Markdown (format: "emailmd", you write markdown, Tratto
renders it into responsive, email-safe HTML — dark mode, Outlook fixes and a
plain-text part included).
With a Markdown template:
- The markdown is the source of truth — it lives in the template's
sourcefield. Thehtmlfield is derived from it by the server at save time and cannot be set directly. - Rendering happens when you save, not when you send. The rendered HTML is pinned to the template (and to each version), so delivery is byte-stable.
{{variables}}pass through the render untouched and are substituted at send time, exactly like in HTML templates — including inside link URLs.
Prefer clicking to reading? The dashboard has a full markdown editor with live preview — see Markdown templates in the dashboard.
Syntax
Everything below is standard markdown plus a few email-specific additions. Regular markdown works as you expect:
## A heading
Some **bold** text, a [link](https://example.com), and a list:
- First item
- Second itemFrontmatter
An optional YAML block at the very top of the document sets email metadata and a few document-level options:
---
preheader: "Your order has shipped. Track it now"
lang: en
dir: ltr
breaks: false
theme: auto
---Those five keys are the whole list — Tratto passes only these to the renderer:
| Key | What it does |
|---|---|
preheader | The preview text email clients show next to the subject in the inbox list. Embedded invisibly in the email body. |
lang | The document language, written to the lang attribute of the rendered HTML. A language tag: en, it, pt-BR. |
dir | Text direction — ltr (default), rtl or auto. rtl flips the default alignment to the right. |
breaks | true turns a single newline into a line break; false (the default) keeps standard markdown, where paragraphs are separated by a blank line. |
theme | light, dark, or auto (renders light but adapts to readers whose mail client is in dark mode). Only these three values: anything else is ignored, with a warning. Without it the document uses the workspace theme, and light if the workspace has none. |
Every other key is removed before the render and reported as a render warning. In particular:
- Colours (
brand_color,button_color,divider_color,background_color,content_color, …) are set in your workspace brand settings, not in the template — see Making a campaign look different. The render applies the workspace brand as a guarantee, not as a default that a document can overwrite. font_familyandfontsare not supported.fontswould load a remote stylesheet inside your recipient's mailbox.dark:(a custom dark palette) is not supported. For a dark rendering usetheme: darkortheme: auto, which use the renderer's own consistent dark palette.
The markdown you save is not modified: the removed line stays in the
template's source exactly as you typed it, it simply has no effect on the
rendered HTML. So seeing brand_color: still sitting there in the editor does
not mean it was applied — the warnings are what tell you.
Directives
Directives are fenced blocks that control email layout:
::: header
# Acme Monthly
:::
## Product updates
We shipped **three** improvements this month.
::: callout
Early-bird pricing ends **Friday**.
:::
::: footer
Acme Inc — Milano
[Unsubscribe]({{unsubscribe_url}})
:::::: header— a band above the main content area, typically a logo or brand name. Centered by default; acceptsleft/right.::: callout— a highlighted card for the part people must not miss. Space-separated parameters tune it:center/left/rightfor alignment,compact/spaciousfor padding — e.g.::: callout center compact.::: footer— smaller, muted text at the bottom for legal text, unsubscribe links and company information.::: include brand-header: the workspace logo, in a header band. A single line, with no closing:::.
The workspace logo
Uploading a logo does not put it in every email. A document shows it only if it asks for it, with this line where the logo should go (usually the first line after the frontmatter):
::: include brand-header
# Your order has shippedAt render time the line becomes a ::: header band holding the logo, with the
workspace name as its alt text. It is opt-in on purpose: a logo belongs on a
newsletter, not necessarily on a password reset, and deleting the line is
enough to drop it from one template.
The logo is drawn inside a 160 × 64 px box: scaled down to fit, keeping its proportions, and never enlarged. A logo smaller than the box is drawn at its own size. Upload rules and the warnings about small or heavy files are in Branding.
If the workspace has no logo, the line renders nothing and the render reports
the warning Unknown partial "brand-header". The rest of the email is
unaffected.
Buttons
Turn any link into a call-to-action button with the {button} attribute:
[Confirm email](https://app.example.com/confirm?token={{token}}){button}
[Learn more](https://example.com){button.secondary}Variables
Use {{variableName}} placeholders anywhere — headings, prose, link URLs.
They survive the markdown render intact and are substituted when the email is
sent, with the variables you pass to POST /v1/emails or a test send.
This is why the saved template's HTML still contains the literal placeholders:
that is by design.
Making a campaign look different
Theme, colours and logo are a workspace setting, shared by every
template. A document can only pick its own theme and choose whether to show
the logo.
-
Workspace brand: a default theme, the three brand colours and the logo are set in Settings → Workspace in the dashboard, or through
PATCH /v1/workspace(thebrandobject) andPUT /v1/workspace/brand/logo(API details in Branding). The colours land here:Brand field Where it shows in the email primaryColorLinks, and the background of primary buttons ( {button})secondaryColorBorder and label of secondary buttons ( {button.secondary})accentColorDividers and horizontal rules themeThe palette ( light,dark,auto) of documents that do not set their owntheme -
Per document:
theme: light,theme: darkortheme: autoin the frontmatter overrides the workspace theme, and::: include brand-headershows the logo. That is the whole list.
The brand is applied when the markdown is rendered, not when the email is
sent. A brand change reaches every render after it, but not the HTML already
pinned on templates and campaigns saved earlier. A template is re-rendered only
when its markdown changes: saving the same markdown again does not pick up a
new logo or new colours. Edit the markdown (even a single character) and save,
or check the result first with render-preview, which always uses the current
brand.
There is no per-template colour, font or custom palette override, and that is deliberate: what the workspace configured is what recipients get.
Security: raw HTML is escaped
Markdown templates are rendered with HTML input disabled. Anything that looks
like raw HTML in the markdown (<script>, <img onerror=…>, any tag) is
escaped and shown as literal text, and javascript: / data: links are
never turned into links. If you need hand-written HTML, create a template with
format: "html" instead — the two formats cannot be mixed in one template.
End-to-end: markdown → template → test send → campaign
1. Create the template
Send format: "emailmd" and a markdown field. Do not send html — for
this format it is derived, and providing it fails validation (422).
curl -X POST https://api.tratto.email/v1/templates \
-H "Authorization: Bearer tratto_live_..." \
-H "Content-Type: application/json" \
-d '{
"name": "Welcome Email",
"format": "emailmd",
"markdown": "---\npreheader: Your account is ready\n---\n\n# Welcome, {{firstName}}!\n\nThanks for signing up.\n\n[Confirm email](https://app.example.com/confirm?token={{token}}){button}"
}'Response (201):
{
"data": {
"id": "tmpl_abc123",
"name": "Welcome Email",
"status": "draft",
"format": "emailmd",
"source": "---\npreheader: Your account is ready\n---\n\n# Welcome, {{firstName}}!...",
"html": "<!doctype html><html>...</html>",
"renderWarnings": [],
"version": 1,
"createdAt": "2026-08-17T12:00:00Z",
"updatedAt": "2026-08-17T12:00:00Z"
}
}html is the complete rendered document, ready to send. The render also
produces a plain-text part that is delivered as the email's text/plain
alternative. The markdown source is capped at 100,000 characters.
2. Preview without saving
POST /v1/templates/render-preview renders markdown statelessly — it is what
powers the dashboard's live preview:
curl -X POST https://api.tratto.email/v1/templates/render-preview \
-H "Authorization: Bearer tratto_live_..." \
-H "Content-Type: application/json" \
-d '{"markdown": "# Hello {{firstName}}"}'Response (200): { "data": { "html": "...", "text": "...", "warnings": [] } }
It requires the same templates:write permission as saving, and has a
tighter rate limit of 60 requests per minute (the render is CPU-bound),
which applies on top of the global 500/minute per-IP limit rather than
replacing it — see Rate Limits.
3. Iterate: update re-renders
PATCH with a new markdown re-renders and creates a new version. Sending
html to a Markdown template — or markdown to an html template — fails
with 422. The format itself is fixed at creation and can never be changed.
curl -X PATCH https://api.tratto.email/v1/templates/tmpl_abc123 \
-H "Authorization: Bearer tratto_live_..." \
-H "Content-Type: application/json" \
-d '{"markdown": "# Welcome back, {{firstName}}!"}'4. Test send, publish, send
From here on, a Markdown template behaves exactly like an HTML one. The order
matters: the test send works on a draft, but the transactional send at the end
only works once the template is published
(see Templates).
# Test send to your own inbox, with sample variable values
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": "you@example.com", "variables": {"firstName": "Alice", "token": "TEST"}}'
# Publish
curl -X PATCH https://api.tratto.email/v1/templates/tmpl_abc123 \
-H "Authorization: Bearer tratto_live_..." \
-H "Content-Type: application/json" \
-d '{"status": "published"}'
# Send a transactional email with it
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!",
"templateId": "tmpl_abc123",
"variables": {"firstName": "Alice", "token": "abc123"}
}'Campaigns start from a published template too: see the next section.
Editing markdown inside a campaign
A campaign created from a Markdown template owns its own copy of the markdown. From then on the template is where the campaign came from, not a live link:
- At creation the content is copied, not re-rendered. The campaign gets
the template's
sourceand the HTML pinned on it, exactly as you reviewed them in the template editor. - Editing the template later does not change the campaign, and editing the campaign never touches the template.
- To change the text, edit the campaign.
PATCH /v1/campaigns/:idwith amarkdownfield (or the markdown editor in the campaign page of the dashboard) re-renders the campaign's HTML with the current workspace brand.
curl -X PATCH https://api.tratto.email/v1/campaigns/camp_xyz789 \
-H "Authorization: Bearer tratto_live_..." \
-H "Content-Type: application/json" \
-d '{"markdown": "::: include brand-header\n\n# October news\n\nThree things we shipped."}'The same rules as for templates apply:
| Request | Result |
|---|---|
markdown on a Markdown campaign in draft | Re-rendered and saved; warnings in the campaign's renderWarnings |
html on a Markdown campaign, or markdown on an HTML one | 422 VALIDATION_ERROR |
html and markdown together | 422 VALIDATION_ERROR |
Any edit on a campaign that is no longer draft | 409 CONFLICT |
templateId | Replaces the content with a fresh copy of that template (which must be published). It is the only way to change a campaign's format |
At delivery, click tracking, the open pixel and the unsubscribe footer are applied exactly as for HTML campaigns.
Sending markdown without a template
POST /v1/emails also accepts a one-off markdown body — no template
involved. See Send Email.
Render warnings
The render never fails on imperfect input — problems degrade gracefully and
are reported as warnings. On a saved template they are stored in
renderWarnings; on render-preview they come back in warnings. Treat them
as a checklist, not as errors: the template still saved and still sends.
The ones you are most likely to meet:
- A frontmatter key that is not one of the five above — it was removed before the render; the warning names the key. Colour and font keys always land here.
- An invalid value on a supported key — an unknown
theme, adirthat is notltr/rtl/auto, a malformedlang, abreaksthat is not a boolean. The renderer falls back to the default and says so. - An unknown directive parameter — e.g. a word on a
::: calloutline that is not one of the documented ones. Unknown partial "brand-header": the document includes the logo but the workspace has none uploaded. Nothing is drawn in its place.
Only two situations reject the request outright, both with
VALIDATION_ERROR (422):
- Empty markdown —
"markdown must not be empty." - Conflicting fields —
markdowntogether withhtml, on templates or onPOST /v1/emails.
See Error Codes for the payloads.
Using the SDKs
Markdown support requires SDK 1.1.0 or newer, on both npm and PyPI, so a fresh install has it; on an older pin, use the REST API directly as shown above.
Node.js (@tratto/email, 1.1.0+)
// One-off markdown email — no template
const email = await client.emails.send({
from: 'hello@yourdomain.com',
to: 'user@example.com',
subject: 'Welcome!',
markdown: '# Welcome, {{firstName}}!',
variables: { firstName: 'Alice' },
});
// Create a Markdown template
const template = await client.templates.create({
name: 'Welcome Email',
markdown: '# Welcome, {{firstName}}!',
});
console.log(template.format); // 'emailmd'
console.log(template.renderWarnings); // []Python (tratto-email, 1.1.0+)
from tratto import SendEmailOptions, CreateTemplateOptions
# One-off markdown email — no template
client.emails.send(SendEmailOptions(
from_='hello@yourdomain.com',
to='user@example.com',
subject='Welcome!',
markdown='# Welcome, {{firstName}}!',
variables={'firstName': 'Alice'},
))
# Create a Markdown template
template = client.templates.create(CreateTemplateOptions(
name='Welcome Email',
markdown='# Welcome, {{firstName}}!',
))PHP (tratto/email, 1.0.0+)
// One-off markdown email, no template
$tratto->emails->send([
'from' => 'hello@yourdomain.com',
'to' => 'user@example.com',
'subject' => 'Welcome!',
'markdown' => '# Welcome, {{firstName}}!',
'variables' => ['firstName' => 'Alice'],
]);
// Create a Markdown template
$template = $tratto->templates->create([
'name' => 'Welcome Email',
'markdown' => '# Welcome, {{firstName}}!',
]);Next Steps
- Prefer the dashboard? See Markdown templates in the dashboard
- Template basics (publish, versions, test send): Templates
- Send one-off markdown: Send Email
- Use the template in a campaign: Campaigns
Edit this page on GitHub
Last updated on