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.

HelperExampleOutput
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.

The {{{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:

  1. The template body renders against your variables (its own {{fields}} are HTML-escaped).
  2. The layout renders, with the finished template HTML placed at {{{content}}} and the same variables available 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 & pathDescription
POST /v1/layoutsCreate a layout (slug, name, body, optional textBody).
GET /v1/layoutsList 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.