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 <[email protected]>",
"to": "[email protected]",
"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.
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 (₦ by default; integers show no kobo) |
{{currency total symbol="$"}} | $8,500 | |
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 and dates are formatted for a Nigerian audience (Naira, West Africa Time). Dates
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.