Email Template Extensions
Email template extensions let your app ship branded HTML for the
transactional emails LaunchMyStore sends on the merchant’s behalf — order
confirmations, shipment notifications, password resets, and more. The
template is stored as part of the merchant’s store and is
selected at send time by event name.
This replaces the platform’s built-in default HTML for any
event the app overrides. Merchants can also save their own templates
through the admin UI, with app-provided templates winning on conflict.
MVP status. Only the order_confirmation event currently checks the
DB for a template override. The other events listed below are still
served from the hardcoded fallback HTML — your registered template is
stored, but the email send path has not been refactored yet. New events
will start honoring the DB template as they are wired in.
How Email Templates Work
- Your app sends a template manifest at install time (or any time after,
via the app-scoped REST API).
- LaunchMyStore upserts a template record, keyed by
(store, event, app).
- When the merchant’s store fires the corresponding event (e.g. an order
is placed), the send pipeline selects the active template for that
store and event using the resolution rules.
- The chosen
htmlBody is rendered with the full Aqua (Liquid) engine
(interpolation, loops, conditionals, filters) and sent through the
merchant’s configured email provider — the platform’s shared sender by
default, or the merchant’s own SMTP if one is set. Custom templates
only apply when the merchant has configured their own SMTP, to protect
the platform’s shared sender reputation.
Architecture
Two separate channels connect your app to LaunchMyStore — only one of them
carries email templates.
┌─────────────────────────────────────┐
│ Your app's backend │
│ (runs on your own server) │
└──────┬────────────────────────┬─────┘
│ │
┌────────────┘ └────────────┐
│ │
▼ ▼
┌─────────────────────┐ ┌──────────────────────────┐
│ iframe (browser) │ │ Server-to-server POST │
│ │ │ (no browser involved) │
│ Renders inside │ │ │
│ the merchant │ │ POST /api/v1/ │
│ dashboard │ │ email-templates. │
│ │ │ json │
│ Used for the │ │ │
│ app's admin UI │ │ Authorization: Bearer │
│ (settings page, │ │ <oauth-token> │
│ pickers, etc.) │ │ │
│ │ │ Body: { event, subject, │
│ NOT used for │ │ htmlBody, ... } │
│ template upload │ │ │
└─────────────────────┘ └────────┬─────────────────┘
│
▼
┌───────────────────────┐
│ LaunchMyStore API │
│ │
│ Stores the │
│ template record │
└───────────────────────┘
| Channel | Runs in | Used for |
|---|
| iframe (App Bridge) | Browser, embedded in merchant admin | App’s settings UI, resource pickers, toasts, modals — anything the merchant clicks |
| REST POST | Your backend server | Persisting templates into LaunchMyStore |
The merchant never types HTML into a browser. Your server POSTs the
template directly. The iframe is optional UI for triggering that POST
(e.g. a “Re-sync templates” button).
Two ways the POST happens
At install — LaunchMyStore reads the emailTemplates array from your
install manifest and upserts each entry. No separate auth needed because
the merchant just authorized the install.
Merchant clicks Install
│
▼
LaunchMyStore ──► reads extensions.emailTemplates from manifest
│
▼
Template record stored for each entry
Anytime after install — Your server uses the OAuth access token it
received during install to POST a new template:
Your backend ──► POST /api/v1/email-templates.json
Authorization: Bearer <token>
{ event, subject, htmlBody }
│
▼
LaunchMyStore ──► upserts your app's template
for that event
The appId is read from the bearer token — apps can never write rows
under another app’s appId.
Storage
Each template is stored as one record, uniquely keyed by
(store, event, app). That key is what makes
one merchant + N apps + N events safely concurrent:
store | event | app | record
-----------+--------------------+-------------------+--------------
acme-store | order_confirmation | branded-emails | record A
acme-store | order_confirmation | transactional-pro | record B
acme-store | order_confirmation | (merchant-set) | record C
acme-store | shipment_notif… | branded-emails | record D
Each record is independent; the resolution rules
below pick one at send time.
A read-only copy of each registered template is also kept in your app’s
deployed extension bundle under email-templates/{event}.json, so you
can inspect exactly what was deployed:
{
"event": "order_confirmation",
"subject": "Order {{ order.name }} confirmed — thanks {{ customer.first_name }}!",
"htmlBody": "<!DOCTYPE html>...",
"enabled": true
}
The bundle copy is never read at send time — it exists for developer
inspection (“what did I actually deploy?”) and tooling (CLI pull/sync).
To change a template, re-POST through the API; editing the deployed file
has no effect.
End-to-end read path (at send time)
Customer places an order
│
▼
LaunchMyStore email pipeline
│
│ 1. Load the merchant's email provider settings
│ (custom SMTP host/port/user/pass, or none)
│
│ 2. Check reputation gate (see below):
│ no custom SMTP → skip override, use default
│
│ 3. Select the active template for
│ (store, event) via the resolution rules
│
▼
Render subject + htmlBody with the Aqua (Liquid) engine
│
▼
Send via the merchant's SMTP (or the platform default)
│
▼
Customer's inbox
SMTP Reputation Gate
Even when a template override exists, it only fires if the merchant has
configured their own SMTP. On the platform’s shared sender, the
send falls back to the built-in default template.
Why this gate exists. Imagine 1,000 stores install the same buggy app.
Without the gate, every order email from those stores would go through
the platform’s shared sender with malformed/spammy HTML — and
spam filters would start marking the platform’s sender address
as untrustworthy. Every store on the platform would lose
deliverability.
With the gate, a bad template only fires on stores that configured
their own SMTP. Damage is scoped to that one merchant’s
mail.yourstore.com reputation — not the platform’s.
| Merchant SMTP config | Custom template fires? | Sender reputation at risk |
|---|
| Platform shared sender (default) | ❌ No — default HTML | Platform (protected) |
| Custom SMTP | ✅ Yes — registered template | Merchant’s own |
If an override exists but the gate blocks it, the send falls back to the
default template — the most common reason a registered template “isn’t
working” is that the merchant hasn’t configured their own SMTP yet.
Supported Events
| Event | Status | Description |
|---|
order_confirmation | Wired (MVP) | Sent to the customer when an order is placed |
order_cancellation | Planned | Sent when an order is cancelled |
shipment_notification | Planned | Sent when a shipment goes out |
abandoned_cart | Planned | Sent to recover an abandoned checkout |
password_reset | Planned | Sent when a customer requests a password reset |
account_invitation | Planned | Sent when a merchant invites a customer to claim their account |
newsletter_welcome | Planned | Sent on newsletter signup |
Until an event is “wired,” registering a template for it is a no-op at
send time — the template is stored, but the send pipeline continues to
use the built-in default HTML. Track the changelog for events moving to
Wired status.
Template Variables
Templates are rendered with the full Liquid engine — {{ var.path }}
interpolation, {% for %} loops, {% if %} conditionals, {% assign %},
and built-in filters (upcase, date, truncate, plus, times,
money_without_currency, etc.) all work. Missing values render as
empty strings.
Both the subject and htmlBody fields are rendered with the same
engine and context.
Variables available for order_confirmation
Order — top-level fields about the order:
| Variable | Type | Description |
|---|
{{ order.id }} | string | Public order id (e.g. 1042) |
{{ order.invoice_id }} | string | Invoice id |
{{ order.name }} | string | Display name on the order |
{{ order.total }} | number | Order total (pre-adjustments) |
{{ order.final_price }} | number | Final price the customer paid |
{{ order.currency }} | string | ISO currency code (USD, INR, …) |
{{ order.payment_method }} | string | Payment method label |
{{ order.created_at }} | string | ISO timestamp; pipe through | date |
{{ order.url }} | string | Storefront base URL |
{{ order.address }} | string | Shipping street |
{{ order.address_line_2 }} | string | Shipping address line 2 |
{{ order.city }} | string | Shipping city |
{{ order.state }} | string | Shipping state |
{{ order.country }} | string | Shipping country |
{{ order.pin_code }} | string | Shipping postal code |
{{ order.line_items_count }} | number | Number of line items in the order |
order.line_items | array | Iterable line items — see below |
Line items — each item in order.line_items exposes:
| Variable | Type | Description |
|---|
{{ item.name }} | string | Product name |
{{ item.image }} | string | Product image URL (40×40 recommended) |
{{ item.quantity }} | number | Quantity ordered |
{{ item.unit_price }} | number | Per-unit price (uses discountPrice if non-zero, else salePrice) |
{{ item.line_total }} | number | unit_price × quantity |
Customer:
| Variable | Description |
|---|
{{ customer.first_name }} or {{ customer.firstName }} | First token of customer.name |
{{ customer.name }} | Full name |
{{ customer.email }} | Email address |
{{ customer.mobile }} | Phone number |
Shop:
| Variable | Description |
|---|
{{ shop.name }} | Store / business name |
{{ shop.url }} | Storefront URL |
{{ shop.currency }} | Default currency |
Helpers:
| Variable | Description |
|---|
{{ product_table }} | Pre-rendered HTML <tr> rows for line items — drop straight into a <tbody>. Use this if you don’t want to author your own rows. |
{{ currency_symbol }} | Currency symbol matching shop.currency ($, ₹, €, …) |
Iterating line items
When a customer orders multiple products, loop over order.line_items
to render one row each:
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="background:#f9fafb;">
<th align="left">Item</th>
<th align="center">Qty</th>
<th align="right">Price</th>
<th align="right">Total</th>
</tr>
</thead>
<tbody>
{% for item in order.line_items %}
<tr style="border-bottom:1px solid #f3f4f6;">
<td style="padding:12px;">
<img src="{{ item.image }}" width="40" height="40"
style="vertical-align:middle;border-radius:6px;" />
<span style="margin-left:8px;">{{ item.name }}</span>
</td>
<td align="center">{{ item.quantity }}</td>
<td align="right">{{ currency_symbol }}{{ item.unit_price }}</td>
<td align="right"><strong>{{ currency_symbol }}{{ item.line_total }}</strong></td>
</tr>
{% endfor %}
</tbody>
</table>
Given a 2-item order, this renders two <tr> rows. Inside the loop you
also have access to the standard Liquid forloop object:
{% for item in order.line_items %}
<tr style="background:{% cycle '#ffffff', '#f9fafb' %};">
<td>{{ forloop.index }}. {{ item.name | truncate: 40 }}</td>
<td>{{ item.quantity }} × {{ currency_symbol }}{{ item.unit_price }}</td>
<td>
{% if item.quantity > 1 %}
<strong>{{ currency_symbol }}{{ item.line_total }}</strong>
{% else %}
{{ currency_symbol }}{{ item.line_total }}
{% endif %}
</td>
</tr>
{% endfor %}
Useful built-in filters
| Filter | Example | Result |
|---|
upcase / downcase | {{ customer.first_name | upcase }} | JOHN |
truncate | {{ item.name | truncate: 20 }} | Strawberry Milk - Va... |
date | {{ order.created_at | date: "%b %d, %Y" }} | Apr 12, 2026 |
plus / minus / times / divided_by | {{ item.unit_price | times: item.quantity }} | Per-row subtotal |
round | {{ order.total | round: 2 }} | 42.99 |
default | {{ order.address_line_2 | default: "" }} | Empty if missing |
escape | {{ item.name | escape }} | HTML-safe text |
Template Manifest
Each entry under extensions.emailTemplates is one event override.
{
"handle": "branded-emails",
"name": "Branded Email Templates",
"version": "1.0.0",
"extensions": {
"emailTemplates": [
{
"event": "order_confirmation",
"subject": "Order {{ order.id }} confirmed — thanks {{ customer.firstName }}!",
"htmlBody": "<!DOCTYPE html><html><body style=\"font-family: Arial, sans-serif;\"><h1>Thank you, {{ customer.firstName }}</h1><p>Your order <strong>{{ order.id }}</strong> is in.</p>{{ product_table }}<p>Total: {{ currency_symbol }}{{ order.total }}</p><p>We will email you again when it ships.</p></body></html>",
"enabled": true
}
]
}
}
Fields
| Field | Required | Description |
|---|
event | yes | Event name (see Supported events). Lowercase, snake_case. |
htmlBody | yes | HTML template string. Run through the variable substitution before send. |
subject | no | Subject line template. Defaults to the legacy subject for the event. |
enabled | no | Disable the override without deleting it. Defaults to true. |
appId | auto | Set automatically for app-scoped writes. Merchant-set rows have appId = null. |
Resolution Rules
For each (storeId, event), the active template is selected as follows:
enabled = false rows are skipped entirely.
- App-provided rows win over merchant-set rows. Rationale: the
merchant explicitly installed the app that ships the template, so the
override is opt-in.
- Within the same app bucket, newest
createdAt wins. Developers
can iterate on their template without manual deletion.
If no row matches, LaunchMyStore falls back to the legacy hardcoded HTML
for that event.
┌─────────────────────────────────────────────────────────────┐
│ Active template for (storeId=X, event=order_confirmation) │
├─────────────────────────────────────────────────────────────┤
│ enabled = true ✓ │
│ ↓ │
│ appId IS NOT NULL → highest priority │
│ ↓ │
│ createdAt DESC → newest first │
└─────────────────────────────────────────────────────────────┘
REST API
Two parallel controllers — pick the one that matches your auth context.
Merchant-scoped (Bearer JWT)
Used by the admin UI when a merchant edits a template directly. storeId
is resolved from the JWT.
| Method | Path | Description |
|---|
GET | /email-templates?event= | List templates for the store. Filter by event or appId. |
GET | /email-templates/:id | Read a single template. |
POST | /email-templates | Upsert keyed by (storeId, event, appId). |
PUT | /email-templates/:id | Update subject / htmlBody / enabled. |
DELETE | /email-templates/:id | Remove the row. |
App-scoped (OAuth Bearer)
Used by your app after install. The appId field is forced to the
authenticated app’s id — apps cannot impersonate each other.
Required scopes: read_email_templates, write_email_templates.
| Method | Path | Description |
|---|
GET | /api/v1/email-templates.json?event= | List the current app’s templates for the store. |
POST | /api/v1/email-templates.json | Upsert the current app’s template for an event. |
DELETE | /api/v1/email-templates/:id.json | Delete one of the current app’s templates. |
Upsert example
curl -X POST https://api.launchmystore.io/api/v1/email-templates.json \
-H "Authorization: Bearer <app-access-token>" \
-H "Content-Type: application/json" \
-d '{
"event": "order_confirmation",
"subject": "Order {{ order.id }} confirmed",
"htmlBody": "<!DOCTYPE html>...",
"enabled": true
}'
Response:
{
"status": 201,
"message": "success",
"template": {
"id": "et_8a7f...",
"store_id": "store_...",
"app_id": "app_...",
"event": "order_confirmation",
"subject": "Order {{ order.id }} confirmed",
"html_body": "<!DOCTYPE html>...",
"enabled": true,
"created_at": "...",
"updated_at": "..."
}
}
Installing at App Install
The recommended flow is to ship your templates inline with the rest of
your extension files during install. Add an emailTemplates array to
the extensions payload of POST /api/apps/install-extensions:
curl -X POST https://store.launchmystore.io/api/apps/install-extensions \
-H "Content-Type: application/json" \
-d '{
"domainSlug": "acme-store",
"appHandle": "branded-emails",
"extensions": {
"emailTemplates": [
{
"event": "order_confirmation",
"subject": "Order {{ order.id }} confirmed",
"htmlBody": "<!DOCTYPE html>...",
"enabled": true
}
]
}
}'
This single call stores the canonical template record and deploys the
read-only bundle copy — see Storage for the full layout.
Complete Example: Order Confirmation Template
A branded order confirmation that uses all of the MVP-supported variables.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif;
max-width: 600px;
margin: 0 auto;
color: #1a1a1a;
}
.header { padding: 24px; background: #f7f7f7; }
.body { padding: 24px; }
.totals { border-top: 1px solid #e1e3e5; padding-top: 16px; }
.footer { padding: 24px; color: #6b7177; font-size: 12px; }
</style>
</head>
<body>
<div class="header">
<h1>{{ shop.name }}</h1>
</div>
<div class="body">
<h2>Thank you, {{ customer.firstName }}.</h2>
<p>
Your order <strong>{{ order.id }}</strong> was placed on
{{ order.created_at }}.
</p>
{{ product_table }}
<div class="totals">
<p><strong>Total:</strong> {{ currency_symbol }}{{ order.total }}</p>
<p><strong>Paid with:</strong> {{ order.payment_method }}</p>
</div>
<h3>Shipping to</h3>
<address>
{{ customer.name }}<br />
{{ order.address }}<br />
{{ order.city }}, {{ order.state }} {{ order.pin_code }}<br />
{{ order.country }}
</address>
<p>
<a href="{{ order.url }}">View your order</a>
</p>
</div>
<div class="footer">
Sent by {{ shop.name }} · {{ shop.url }}
</div>
</body>
</html>
See Also
- App Listing — declaring scopes for
email-template access.
- Aqua Filters — full reference of the Liquid filters
available in both theme rendering and email templates.
- App Bridge Overview — the iframe channel for
merchant-facing admin UI, separate from this REST-based template flow.
- Webhooks — programmatic alternative
if you want to send email yourself instead of overriding the
built-in template.