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.

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

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.