Skip to main content

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

  1. Your app sends a template manifest at install time (or any time after, via the app-scoped REST API).
  2. LaunchMyStore upserts a template record, keyed by (store, event, app).
  3. 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.
  4. 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      │
                                              └───────────────────────┘
ChannelRuns inUsed for
iframe (App Bridge)Browser, embedded in merchant adminApp’s settings UI, resource pickers, toasts, modals — anything the merchant clicks
REST POSTYour backend serverPersisting 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 configCustom template fires?Sender reputation at risk
Platform shared sender (default)❌ No — default HTMLPlatform (protected)
Custom SMTP✅ Yes — registered templateMerchant’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

EventStatusDescription
order_confirmationWired (MVP)Sent to the customer when an order is placed
order_cancellationPlannedSent when an order is cancelled
shipment_notificationPlannedSent when a shipment goes out
abandoned_cartPlannedSent to recover an abandoned checkout
password_resetPlannedSent when a customer requests a password reset
account_invitationPlannedSent when a merchant invites a customer to claim their account
newsletter_welcomePlannedSent 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:
VariableTypeDescription
{{ order.id }}stringPublic order id (e.g. 1042)
{{ order.invoice_id }}stringInvoice id
{{ order.name }}stringDisplay name on the order
{{ order.total }}numberOrder total (pre-adjustments)
{{ order.final_price }}numberFinal price the customer paid
{{ order.currency }}stringISO currency code (USD, INR, …)
{{ order.payment_method }}stringPayment method label
{{ order.created_at }}stringISO timestamp; pipe through | date
{{ order.url }}stringStorefront base URL
{{ order.address }}stringShipping street
{{ order.address_line_2 }}stringShipping address line 2
{{ order.city }}stringShipping city
{{ order.state }}stringShipping state
{{ order.country }}stringShipping country
{{ order.pin_code }}stringShipping postal code
{{ order.line_items_count }}numberNumber of line items in the order
order.line_itemsarrayIterable line items — see below
Line items — each item in order.line_items exposes:
VariableTypeDescription
{{ item.name }}stringProduct name
{{ item.image }}stringProduct image URL (40×40 recommended)
{{ item.quantity }}numberQuantity ordered
{{ item.unit_price }}numberPer-unit price (uses discountPrice if non-zero, else salePrice)
{{ item.line_total }}numberunit_price × quantity
Customer:
VariableDescription
{{ customer.first_name }} or {{ customer.firstName }}First token of customer.name
{{ customer.name }}Full name
{{ customer.email }}Email address
{{ customer.mobile }}Phone number
Shop:
VariableDescription
{{ shop.name }}Store / business name
{{ shop.url }}Storefront URL
{{ shop.currency }}Default currency
Helpers:
VariableDescription
{{ 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

FilterExampleResult
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

FieldRequiredDescription
eventyesEvent name (see Supported events). Lowercase, snake_case.
htmlBodyyesHTML template string. Run through the variable substitution before send.
subjectnoSubject line template. Defaults to the legacy subject for the event.
enablednoDisable the override without deleting it. Defaults to true.
appIdautoSet automatically for app-scoped writes. Merchant-set rows have appId = null.

Resolution Rules

For each (storeId, event), the active template is selected as follows:
  1. enabled = false rows are skipped entirely.
  2. 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.
  3. 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.
MethodPathDescription
GET/email-templates?event=List templates for the store. Filter by event or appId.
GET/email-templates/:idRead a single template.
POST/email-templatesUpsert keyed by (storeId, event, appId).
PUT/email-templates/:idUpdate subject / htmlBody / enabled.
DELETE/email-templates/:idRemove 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.
MethodPathDescription
GET/api/v1/email-templates.json?event=List the current app’s templates for the store.
POST/api/v1/email-templates.jsonUpsert the current app’s template for an event.
DELETE/api/v1/email-templates/:id.jsonDelete 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.