Skip to main content
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, and are what a 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. 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.40usagechargeitreads‘12.40‘evenona12.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 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. 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 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. 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 and Usage view crosses a threshold, also carry the properties your app sent with that event. Every scalar one becomes a variable named properties.<property>:
gives you properties.total_price, properties.currency, properties.gift and properties.customer.tier, in variable conditions 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:
The flow starts on Custom event is tracked watching order_created, and a variable condition on properties.order_number equals 1000 keeps every other order out. The email on the Yes branch reads:
and reaches the merchant as:
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 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: 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 fires for an event, so the event is its subject.
  • Usage view crosses a threshold 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. 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: 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. The same names work on both triggers that carry an event, so a Mantle milestone template lands on either: see Custom event is tracked and Usage view crosses a threshold.

Custom field variables

Every custom field 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: 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.

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 and Usage view crosses a threshold 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.
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.
Writing is the one place empty does not mean empty. A 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.
Last modified on October 5, 2026