Templates & Layouts
Store reusable email content as templates with Handlebars variables, and wrap them in shared layouts so every email carries the same header, footer and branding — without copy-pasting HTML into each template.
Templates
A template is a named piece of email content — a subject and an HTML body (plus an optional plain-text body) — with {{variables}} that you fill in at send time. Templates are referenced by a slug. Create and edit them in the dashboard or through the API.
Variables use Handlebars syntax: {{firstName}} for a value (HTML-escaped), {{#if plan}}…{{/if}} for conditionals, and {{#each items}}…{{/each}} for loops — handy for receipts with line items.
Create a template
curl -X POST https://api.quolle.com/v1/templates \
-H "Authorization: Bearer qle_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"slug": "welcome-email",
"name": "Welcome email",
"subject": "Welcome to {{company}}, {{firstName}}!",
"body": "<h1>Hi {{firstName}}</h1><p>Thanks for joining {{company}}.</p>"
}'const res = await fetch("https://api.quolle.com/v1/templates", {
method: "POST",
headers: {
"Authorization": "Bearer qle_your_api_key",
"Content-Type": "application/json",
},
body: JSON.stringify({
slug: "welcome-email",
name: "Welcome email",
subject: "Welcome to {{company}}, {{firstName}}!",
body: "<h1>Hi {{firstName}}</h1><p>Thanks for joining {{company}}.</p>",
}),
});import requests
requests.post(
"https://api.quolle.com/v1/templates",
headers={"Authorization": "Bearer qle_your_api_key"},
json={
"slug": "welcome-email",
"name": "Welcome email",
"subject": "Welcome to {{company}}, {{firstName}}!",
"body": "<h1>Hi {{firstName}}</h1><p>Thanks for joining {{company}}.</p>",
},
timeout=15,
)Send with a template
curl -X POST https://api.quolle.com/v1/emails/send \
-H "Authorization: Bearer qle_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <hello@mail.yourdomain.com>",
"to": "customer@example.com",
"template": "welcome-email",
"variables": { "firstName": "Amaka", "company": "Acme" }
}'The variables fill the template's {{placeholders}}. Missing a variable that the template references returns a 422 — so what you send is always complete. See Send email for the full send API.
Drafts
A template can be saved as a draft — work in progress, not ready to reach anybody. Pass "draft": true when creating or updating one.
curl -X POST https://api.quolle.com/v1/templates \
-H "Authorization: Bearer qle_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"slug": "winback",
"name": "Win-back",
"subject": "We miss you",
"body": "<p>Come back</p>",
"draft": true
}'Publishing is just clearing the flag — PUT the template with "draft": false. Every field is optional, so this sends nothing else and changes nothing else. There is no separate publish endpoint, because “publish” and “edit” are the same write; splitting them would let a template go live in a state nobody saved.
curl -X PUT https://api.quolle.com/v1/templates/<template_id> \
-H "Authorization: Bearer qle_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "draft": false }'A draft cannot be sent. Using one in POST /v1/emails/send, a broadcast, or an automation step is refused — 422 on send, and enabling an automation whose steps reference a draft fails naming every step that does. Templates default to published, so this only affects templates you explicitly marked as drafts.
Helpers
Beyond plain {{variables}}, templates and layouts can use a small set of built-in helpers for formatting. They're pure formatters — no logic, no side effects.
| Helper | Example | Output |
|---|---|---|
currency | {{currency total}} | ₦8,500 (NGN default; integers show no minor unit) |
{{currency total "KES"}} | KSh 8,500 (positional ISO code) | |
{{currency total code="GHS"}} | GH₵8,500 | |
{{currency total symbol="$"}} | $8,500 (custom symbol) | |
date | {{date orderDate}} | 24 Jul 2026 (WAT) |
datetime | {{datetime createdAt}} | 24 Jul 2026, 14:30 WAT |
default | {{default nickname "there"}} | the value, or there if it's null/empty |
eq | {{#if (eq status "paid")}}…{{/if}} | conditional on equality |
uppercase / lowercase | {{uppercase code}} | ABC123 |
currency defaults to Naira (NGN); pass an ISO 4217 code — positionally ({{currency total "KES"}}) or as code="KES" — for others. Supported codes: NGN, GHS, KES, ZAR, EGP, XOF, XAF, UGX, TZS, RWF, ETB, MAD, DZD, TND, ZMW, MWK, BWP, MUR, NAD, AOA, MZN, CDF, plus USD, EUR and GBP. Each uses its correct symbol and minor-unit precision — zero-decimal currencies like XOF/XAF/UGX/RWF omit the fraction; an unknown code falls back to the code itself as a prefix. Override with symbol= or decimals= when you need to.
Dates are formatted in West Africa Time and accept an ISO 8601 string or timestamp. Combined with the built-in Handlebars {{#each}}, these make itemised receipts easy:
<table>
{{#each items}}
<tr>
<td>{{name}} × {{default quantity 1}}</td>
<td>{{currency price}}</td>
</tr>
{{/each}}
<tr><td><strong>Total</strong></td><td><strong>{{currency total}}</strong></td></tr>
</table>Every referenced variable must be present at send time (missing ones return a 422), so what lands in the inbox is always complete.
Layouts
A layout is a reusable wrapper — your header, footer, logo, colours and unsubscribe line — that a template renders into. Instead of pasting the same shell into every template, you define it once as a layout and attach it to any template by slug. Change the footer in one place and every email that uses the layout updates.
{{{content}}} contract. A layout body must contain the placeholder {{{content}}} — written with triple braces. That's where the rendered template gets injected. Triple braces mean "insert raw, unescaped" — the template's HTML is already rendered, so the double-brace form {{content}} would escape the tags and show them as text. Quolle rejects a layout without a raw {{{content}}} with a 422.Create a layout
curl -X POST https://api.quolle.com/v1/layouts \
-H "Authorization: Bearer qle_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"slug": "brand-default",
"name": "Brand default",
"body": "<div style=\"font-family:sans-serif\"><header>{{company}}</header>{{{content}}}<footer>© {{year}} {{company}}</footer></div>"
}'The layout can use its own {{variables}} (like {{company}} and {{year}}) alongside {{{content}}}. At send time the layout receives the same variables you pass to the send call, so branding values and template values come from one place.
Attach a layout to a template
Set layout to the layout's slug when creating or updating a template. Use null to detach it, or omit the field to leave it unchanged.
curl -X PUT https://api.quolle.com/v1/templates/<template_id> \
-H "Authorization: Bearer qle_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "layout": "brand-default" }'await fetch(`https://api.quolle.com/v1/templates/${templateId}`, {
method: "PUT",
headers: {
"Authorization": "Bearer qle_your_api_key",
"Content-Type": "application/json",
},
body: JSON.stringify({ layout: "brand-default" }), // null to detach
});import requests
requests.put(
f"https://api.quolle.com/v1/templates/{template_id}",
headers={"Authorization": "Bearer qle_your_api_key"},
json={"layout": "brand-default"}, # None to detach
timeout=15,
)How rendering nests
When you send a template that has a layout attached, Quolle renders in two passes:
- The template body renders against your
variables(its own{{fields}}are HTML-escaped). - The layout renders, with the finished template HTML placed at
{{{content}}}and the samevariablesavailable for branding.
The reserved key content always refers to the rendered template — if you also pass a variable named content, the template body wins inside the layout. Plain-text layouts work the same way and also use {{{content}}} (raw), so the text body isn't double-escaped.
Managing layouts
| Method & path | Description |
|---|---|
POST /v1/layouts | Create a layout (slug, name, body, optional textBody). |
GET /v1/layouts | List layouts with a count of templates using each. |
GET /v1/layouts/<id> | Fetch one layout including its full body. |
PUT /v1/layouts/<id> | Update a layout. |
DELETE /v1/layouts/<id> | Delete a layout. Templates using it fall back to standalone rendering — their sends never break. |
Prefer a UI? Create and preview layouts on the Layouts page in the dashboard, then pick one from the Layout dropdown when editing a template.