> ## Documentation Index
> Fetch the complete documentation index at: https://docs.launchmystore.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Email Template Extensions

> Customize transactional emails sent from the store

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

<Warning>
  **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.
</Warning>

## 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](#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      │
                                              └───────────────────────┘
```

| 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](#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:

```json theme={null}
{
  "event": "order_confirmation",
  "subject": "Order {{ order.name }} confirmed — thanks {{ customer.first_name }}!",
  "htmlBody": "<!DOCTYPE html>...",
  "enabled": true
}
```

<Note>
  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.
</Note>

## 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                                      |

<Note>
  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.
</Note>

## 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:

```liquid theme={null}
<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:

```liquid theme={null}
{% 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.

```json theme={null}
{
  "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](#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:

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.

| 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

```bash theme={null}
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:

```json theme={null}
{
  "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`:

```bash theme={null}
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](#storage) for the full layout.

## Complete Example: Order Confirmation Template

A branded order confirmation that uses all of the MVP-supported variables.

```html theme={null}
<!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](/getting-started/app-listing) — declaring scopes for
  email-template access.
* [Aqua Filters](/aqua/filters) — full reference of the Liquid filters
  available in both theme rendering and email templates.
* [App Bridge Overview](/app-bridge/overview) — the iframe channel for
  merchant-facing admin UI, separate from this REST-based template flow.
* [Webhooks](/api-reference/webhooks/topics) — programmatic alternative
  if you want to send email yourself instead of overriding the
  built-in template.
