> ## Documentation Index
> Fetch the complete documentation index at: https://help.the-meridian.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Variables

Variables are the values a flow can read: the store's details, whatever the triggering event carried, your own custom fields. They fill `{{tokens}}` in an email, feed [variable conditions](/condition-variable), and are what a [Populate custom field](/populate-custom-field) action can write onto the store.

Meridian serves one catalog to every editor, so a variable you can branch on is the same variable you can print, and the same one you can store.

## Store variables

Resolved from the store the flow is running for, and available on **every** trigger.

| Variable | Sample |
| - | - |
| `store_name` | Barebones Store |
| `store_domain` | barebones.myshopify.com |
| `store_primary_domain` | barebones.com |
| `app_url` | [https://barebones.myshopify.com/admin/apps/1a2b3c4d5e6f](https://barebones.myshopify.com/admin/apps/1a2b3c4d5e6f) |
| `plan_name` | Pro |
| `email` | [owner@example.com](mailto:owner@example.com) |
| `store_status` | installed |
| `store_currency` | USD |
| `store_country` | US |
| `store_language` | en |
| `store_installed_at` | 2026-01-05 |
| `store_active_days` | 124 |
| `store_total_revenue` | 2450.00 |
| `store_monthly_revenue` | 49.00 |
| `store_total_charges` | 12 |
| `store_last_charge_amount` | 49.00 |
| `store_last_payment_at` | 2026-02-01 |
| `store_is_test` | false |

Dates render as `YYYY-MM-DD` and money to two decimals. The currency is its own variable rather than being formatted into the amounts, so you control how a figure reads.

Three of them are easy to mix up:

* `plan_name` is the store's current subscription plan. A store whose subscription ended (frozen, cancelled or uninstalled) keeps the last plan it subscribed to, and a store that never subscribed reads `Free`. A usage or one-time charge never becomes the plan name.
* `store_currency` is the currency the store is billed in: its recurring revenue's, or its last charge's when it has none. Amounts are never converted.
* `store_last_charge_amount` is the store's most recent charge of any kind (subscription, usage or one-time), so after a $12.40 usage charge it reads `12.40` even on a $49.95 plan. `store_last_payment_at` is that same charge's date.

`store_primary_domain` is the storefront's own domain, resolved from Shopify at [identify](/sdk-identify) time. It is empty for a store that has no custom domain, and for one that has never identified, so build links on `store_domain`, which is always there, and use `store_primary_domain` when you want to address the merchant by the domain they know.

`app_url` opens your app inside the merchant's Shopify admin: `https://{store_domain}/admin/apps/{your Client ID}`, the same link Shopify sends a merchant to after install. It works the same whether Meridian hosts the app or not, because it needs only the store and the **Client ID** in your [app settings](/app-settings#shopify-connection). Until that Client ID is set, it opens the store's app list instead. Without a store (a send with no store behind it) it is empty, like every store variable. The starter [templates](/templates) link their buttons to it, so `href="{{app_url}}"` is the link to use for "open the app".

`store_country` is the country on the merchant's Shopify shop address, as a two-letter ISO 3166-1 code (`FR`, `US`), and `store_language` is the store's primary locale (`en`, `pt-BR`). Both are read from Shopify at identify time, like `store_primary_domain`, so both are empty for a store that has never identified. `store_language` is empty too when your app doesn't hold the `read_locales` scope, because that is the scope the lookup needs.

`store_is_test` reads as the word `true` or `false` rather than as `1` or `0`, the same words a condition compares against. An **Is false** condition on it is how you keep development stores out of a flow.

## Trigger variables

Carried by the event itself, and available **only on the triggers that carry them**. On any other trigger they resolve to an empty string.

| Variable | Triggers | Sample |
| - | - | - |
| `subscription_plan` · `subscription_status` | [Activated](/subscription-activated), [Cancelled](/subscription-cancelled), [Frozen and unfrozen](/subscription-frozen), [Approaching the capped amount](/approaching-capped-amount) | Pro · active |
| `charge_name` | [One-time charge activated](/one-time-charge) | Launch pack |
| `event_key` · `event_name` · `event_quantity` | [Custom event is tracked](/usage-event-tracked), [Usage view crosses a threshold](/usage-view-threshold-crossed) | orders\_synced · Orders synced · 25 |
| `usage_view_key` · `usage_view_name` · `usage_view_event_key` | [Usage view crosses a threshold](/usage-view-threshold-crossed) | gmv · GMV · orders\_synced |
| `usage_view_value` · `usage_view_threshold` | [Usage view crosses a threshold](/usage-view-threshold-crossed) | 10450.5 · 10000 |
| `usage_view_window` · `usage_view_window_start` · `usage_view_window_end` | [Usage view crosses a threshold](/usage-view-threshold-crossed) | billing\_period · 2026-08-03 · 2026-09-02 |

`usage_view_value` is the store's value at the moment it crossed, and
`usage_view_window` is the view's window id (`billing_period`, `calendar_month`,
`last_7_days`, `last_30_days` or `all_time`), the same spelling the SDK reports,
so a condition can compare it. The bounds are UTC dates, and the end is the
**exclusive** instant the window closes (the same bound `getView()` returns). On
an `all_time` view both are **absent**, because the window is unbounded: they
resolve to an empty string, like any absent variable.

## Event properties

The two triggers that fire with a metered event behind them, [Custom event is tracked](/usage-event-tracked) and [Usage view crosses a threshold](/usage-view-threshold-crossed), also carry the **properties** your app sent with that event. Every scalar one becomes a variable named `properties.<property>`:

```ts theme={null}
await client.track({
  eventKey: "orders_processed",
  quantity: 1,
  properties: {
    total_price: 512.5,
    currency: "EUR",
    gift: true,
    customer: { tier: "gold" },
  },
})
```

gives you `properties.total_price`, `properties.currency`, `properties.gift` and `properties.customer.tier`, in [variable conditions](/condition-variable) and as `{{properties.total_price}}` in the emails downstream.

The rules, and they are strict on purpose:

* **Scalars only.** Strings, numbers and booleans are variables. A nested object is walked into (up to three levels: `properties.customer.tier` works, a fourth level does not); a list, and an object itself, are not values, because there is no single way to print a structure.
* **A property you didn't send is absent**, not empty and never guessed. It resolves to an empty string, so it satisfies no comparison except **is empty** and prints as nothing. A condition on it takes the **No** branch.
* **A property present as `null` counts as absent** too. You reported the property, not a value for it.
* **Keys keep their case.** `total_price` and `totalPrice` are two different properties, in conditions and in tokens alike.
* **The `properties.` prefix is what keeps you safe.** An event carrying `plan_name` is `properties.plan_name`; it can never shadow the platform variable of the same name.
* Properties whose names hold anything but letters, digits and `_` (a hyphen, a space) aren't addressable, and a value longer than 255 characters is truncated.

Booleans read as `true` / `false`, the same words a condition compares against.

### From `track()` to the inbox

The property names your app sends are the variable names the flow reads. Nothing renames them on the way, so the two sides of a milestone email line up one to one.

Your app reports the order:

```ts theme={null}
await client.track({
  eventKey: "order_created",
  quantity: 1,
  properties: {
    order_number: 1000,
    order_amount: 512.5,
    currency: "EUR",
    end_customer_name: "Jane Doe",
  },
})
```

The flow starts on [Custom event is tracked](/usage-event-tracked) watching `order_created`, and a [variable condition](/condition-variable) on `properties.order_number` **equals** `1000` keeps every other order out. The email on the Yes branch reads:

```html theme={null}
<p>{{store_name}} just processed its 1000th order.</p>
<p>Order #{{properties.order_number}} from {{properties.end_customer_name}}
came to {{properties.order_amount}} {{properties.currency}}.</p>
```

and reaches the merchant as:

```text theme={null}
Barebones Store just processed its 1000th order.
Order #1000 from Jane Doe came to 512.5 EUR.
```

`store_name` is a store variable and resolves on every trigger. The four `properties.` tokens resolve because this trigger carries the event, and because the app sent those four keys. Rename `order_amount` to `amount` in a release and the token has to follow, the same way a view [totalling it](/usage-views) would.

The condition above works because the milestone is written on the event itself. When it is written on the store's running total instead ("the first order", "the 1000th", "GMV past 10,000"), build the same email on [Usage view crosses a threshold](/usage-view-threshold-crossed): it counts for you, and it carries these same four tokens.

### Which triggers carry properties

Two do, and they are the two that have a recorded event behind them:

* **[Custom event is tracked](/usage-event-tracked)** fires *for* an event, so the event is its subject.
* **[Usage view crosses a threshold](/usage-view-threshold-crossed)** fires because a total moved, but it knows which event moved it and carries that one. So a crossing gives you the view's variables (`usage_view_value`, `usage_view_threshold` and the rest) **and** `event_key`, `event_name`, `event_quantity` and the event's `properties.`, under exactly the names above. That is what lets one email say "your 1000th order, #1000, 512.5 EUR" rather than only "you passed 1000".

When a batch of events crosses the line in one call, the properties carried are those of the event at which the running total reached the threshold, which is the first order for a flow watching for the first. See [Which event, when a batch crosses](/usage-view-threshold-crossed#which-event-when-a-batch-crosses).

On every other trigger there is no event behind the run, so a `{{properties.order_number}}` there renders as nothing and the builder flags it.

### Coming from Mantle

Mantle's Liquid templates expose the same data under a `usageEvent` object. Meridian has **no** `usageEvent` namespace: a `{{usageEvent.properties.order_number}}` token matches nothing, substitutes to an empty string, and the editor marks it as unknown. Move each token to its Meridian name:

| Mantle | Meridian |
| - | - |
| `{{ usageEvent.name }}` | `{{event_name}}` (or `{{event_key}}` for the raw key) |
| `{{ usageEvent.properties.my_property }}` | `{{properties.my_property}}` |
| `{{ usageEvent.timestamp }}` | No equivalent. Send the time as a property (`properties.occurred_at`) if the email needs it |
| `{{ usageEvent.id }}` | No equivalent. Send your own id as a property if you need one |
| `{{ usageEvent.properties }}` (the whole object) | No equivalent. An object is not a value, so print the properties you want one by one |

Merge tags are plain substitution, not Liquid: there are no filters (`| split`, `| date`) and no `{% if %}` blocks. Branching belongs in the flow, as a [variable condition](/condition-variable). The same names work on both triggers that carry an event, so a Mantle milestone template lands on either: see [Custom event is tracked](/usage-event-tracked#what-it-carries) and [Usage view crosses a threshold](/usage-view-threshold-crossed#the-event-that-crossed).

## Custom field variables

Every [custom field](/custom-fields) you define joins the catalog as `custom_field_<handle>`. A field with the handle `customer_score` is `{{custom_field_customer_score}}`.

The prefix is deliberate: it means a field named `plan_name` can't shadow the platform variable of the same name. A checkbox field reads as **Yes** or **No** in an email rather than as `1` or `0`.

## Contact variables

Resolved per recipient, for sends addressed to a store's named [contacts](/send-email):

| Variable | Sample |
| - | - |
| `contact_first_name` | Jane |
| `contact_last_name` | Doe |
| `contact_name` | Jane Doe |

They're empty for every other recipient mode, so a template using them degrades to a generic greeting. **Conditions can't read them**. See [Variable matches a value](/condition-variable).

## Unknown tokens

A token no longer in the catalog substitutes to an **empty string**. A stale template renders with a gap rather than leaking a raw `{{token}}` to a merchant, but it's still a gap, so the builder flags variables that have gone missing.

The same applies to `{{properties.…}}`: a property this event didn't carry renders as nothing. The builder flags one of the two ways that happens, and only one:

* **A `properties.` token on a trigger with no properties behind it is flagged.** Only [Custom event is tracked](/usage-event-tracked) and [Usage view crosses a threshold](/usage-view-threshold-crossed) carry properties, so anywhere else the token could never resolve. That is a fact about the trigger, and the builder knows it.
* **A property your app simply never sends is not**, and cannot be: the catalog cannot know which properties your app sends, and refusing a property your next release starts sending would be exactly the wrong way round. The condition picker shows you what your recent events actually carried instead.

In the editor a `properties.` token chips, highlights and previews like any other variable. It previews as an **empty slot** rather than as an unknown token, because that is what it is: a real variable with no sample to show until a live event carries one.

<Note>
  A `{{token}}` name may hold dots **only** for this namespace. That also means a literal `{{something.else}}` in your HTML (text copied from another templating system, say) is read as a merge tag and substitutes to an **empty string**, not printed as written. If you need braces to survive to the inbox, don't write them as a `{{name}}` pair.
</Note>

Writing is the one place empty does **not** mean empty. A [Populate custom field](/populate-custom-field) action set to a variable that reads nothing leaves the field untouched rather than clearing it: a gap in an email is a cosmetic problem, while a blanked field is lost data.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.