Skip to main content

Liquid Reference

Joryio personalizes messages with Liquid - you write placeholders like {{ user.firstName }} and filters like {{ price | currency }}, and they resolve per recipient at send time. This page is the complete reference: every variable namespace and every Joryio custom filter, with examples.

New to Liquid in Joryio? Start with Liquid Templates for the basics, then come back here when you need the details.

All filters below are registered on one shared Liquid engine used by every render path, so the same template renders identically in email, SMS, WhatsApp, push, in-app, and webhook templates - in campaigns, journeys, and previews alike. What you see in preview is what every channel sends.

Two forgiving defaults worth knowing:

  • A variable with no value renders as empty text - never an error.
  • An unknown filter passes the value through unchanged rather than failing.

Variables​

The available namespaces, at a glance. Follow the links for the full details of each.

NamespaceWhat it holdsDetails
user.firstName, user.lastName, user.email, user.phone, user.id, user.externalId, user.whatsappNameThe contact's default attributesMessage Variables
user.custom.<attribute>Any custom attribute on the contact (e.g. user.custom.plan)Message Variables
trigger.properties.<field>How they entered the journey - the entry event, fixed for the whole runMessage Variables
event.properties.<field>, event.nameThe latest thing they did - the most recent event that advanced the journeyMessage Variables
reply.text, reply.type, reply.profile.nameFriendly aliases for the contact's latest inbound WhatsApp or SMS replyMessage Variables
blocks.<slug>A reusable Content Block, rendered inlineContent Blocks
unsubscribe_url, preferences_url, resubscribe_urlPer-recipient subscription links (email, SMS, WhatsApp session)Liquid Templates
cart.items, cart.value, cart.subtotal, cart.currency, cart.checkoutUrlThe contact's synced store cart - items with name, quantity, price, total, imageUrl, url, sku The cart's value is the total the store sent (it can include shipping); subtotal is the items subtotal, before shipping and tax.Products
last_order.items, last_order.total, last_order.currency, last_order.placedAt, last_order.orderUrlTheir most recent order that was not cancelled or refunded - the same item fields as the cartProducts
top_productsThe products they order most often - up to 5 of name, sku, imageUrl, url, timesOrdered, lastOrderedAt, most frequent firstProducts
products and entity filtersProduct-catalog or custom-entity records from a saved SelectionCatalog feeds below

trigger.*, event.*, and reply.* resolve only inside a journey; the rest work everywhere.

cart, last_order and top_products are empty for a contact with no store orders - wrap them in {% if %}.

Filters​

Chain filters with |, pass arguments after :. For example:

{{ user.firstName | capitalize | default: "there" }}
{{ order.total | currency: "EUR" }}

Text​

FilterWhat it doesExample → Output
capitalizeUppercases the first letter and lowercases the rest{{ "mAYA" | capitalize }} → Maya
uppercaseConverts the whole string to uppercase{{ "sale" | uppercase }} → SALE
lowercaseConverts the whole string to lowercase{{ "SALE" | lowercase }} → sale
truncateCuts a string to a length (default 50) and appends a suffix (default ...). The suffix is added after the cut, on top of the length{{ "The quick brown fox jumps" | truncate: 9 }} → The quick...
strip_htmlRemoves HTML tags{{ "<b>Sale</b> today" | strip_html }} → Sale today
url_encodeURL-encodes a string (for building links){{ "red shoes" | url_encode }} → red%20shoes
pluralizeGiven a number, returns the singular or plural word. Plural defaults to singular + s; pass a third argument for irregular plurals3 {{ 3 | pluralize: "item" }} → 3 items
defaultFallback when the value is null, undefined, or an empty string{{ user.firstName | default: "there" }} → there (when empty)
landing_page_urlA link to a published landing page by its slug. Carries the recipient's token, so the page knows who arrived; an optional argument appends a query string. The link is always built; a slug that is not published (or does not exist) answers 404 at the redirect. Empty only when the slug is malformed or the render has no workspace{{ "spring-sale" | landing_page_url: "utm_campaign=spring" }} → https://api-eu1.joryio.com/api/p/go/<workspace>/spring-sale?jp=…&utm_campaign=spring

Numbers & money​

FilterWhat it doesExample → Output
currencyFormats a number as money. Currency code defaults to USD; pass any ISO code. Uses en-US formatting (symbol first, comma thousands){{ 1249.5 | currency }} → $1,249.50 · {{ order.total | currency: "EUR" }} → €49.90

Dates​

FilterWhat it doesExample → Output
date_formatFormats a date. Styles: short (default), long, full. An unknown style falls back to short. English (en-US) month/day names. Formats in the workspace time zone in previews and sends alike; a date-only value such as a birthday (1990-05-01) is never shifted. A strftime pattern also works ("%d %b %H:%M"), and an optional second argument names another IANA zone - an unknown zone falls back to the workspace zone{{ order.createdAt | date_format }} → Jul 12, 2026 · {{ order.createdAt | date_format: "long" }} → July 12, 2026 · "full" → Sunday, July 12, 2026 · {{ order.createdAt | date_format: "%d %b %H:%M", "America/New_York" }} → 12 Jul 05:30
add_daysAdds N days to a date (negative to subtract). Returns an ISO timestamp - chain date_format to make it readable{{ order.createdAt | add_days: 7 | date_format }} → Jul 19, 2026
time_agoHuman-friendly relative time (year/month/week/day/hour/minute/second granularity){{ user.custom.lastOrderAt | time_ago }} → 3 days ago

Dates and times in message content are shown in the workspace time zone, the same zone the rest of the app uses. This applies to date_format and to the standard date filter ({{ order.createdAt | date: "%H:%M" }}); pass a zone as the last argument to use another one.

Arrays​

FilterWhat it doesExample → Output
joinJoins array items into a string. Separator defaults to , {{ names | join }} → Ana, Ben, Gal · {{ names | join: " / " }} → Ana / Ben / Gal
mapExtracts one property from each item, returning a new array{{ items | map: "name" | join }} → Mug, Tee
firstThe first item{{ items | first }}
lastThe last item{{ items | last }}
sizeLength of an array or a string (0 for anything else){{ cart.items | size }} → 3
countNumber of items in an array (0 if not an array){{ cart.items | count }} → 3

Aggregates & filtering​

These operate on arrays of records - cart items, orders, or catalog selections. Every field argument supports dot-paths into nested objects (e.g. "price.amount").

FilterWhat it doesExample → Output
sumSums a numeric field across the array (missing values count as 0){{ orders | sum: "totalAmount" }} → 540
avgAverages a numeric field (0 for an empty array){{ orders | avg: "totalAmount" }} → 180
maxLargest numeric value of a field (non-numeric values ignored; 0 if none){{ products | max: "price" }} → 129.9
minSmallest numeric value of a field{{ products | min: "price" }} → 19.9
whereFilters the array. Three call shapes: where: "featured" keeps truthy items; where: "category", "shoes" keeps equal items; where: "price", "gt", 100 compares with an operator: eq, neq, gt, gte, lt, lte, contains, in (symbol forms ==, !=, >, >=, <, <= also work){{ items | where: "category", "shoes" | count }} → 2
sortSorts by a field, order asc (default) or desc. With no field, sorts the values themselves{{ products | sort: "price", "desc" | first }} → the most expensive product

A worked example - the recipient's three most recent orders:

{% assign recent = orders | sort: 'createdAt', 'desc' %}
{% for order in recent limit: 3 %}
- {{ order.createdAt | date_format }}: {{ order.totalAmount | currency }}
{% endfor %}
Total spent: {{ orders | sum: 'totalAmount' | currency }}

Catalog feeds​

Two filters pull collections of records into a message, each backed by a saved Selection scoped to your workspace. Both return an array - use them in an assign tag, then loop.

products - store product catalog​

Pulls from your synced product catalog (Shopify / WooCommerce / Magento) via a product Selection. There is one product catalog, so the argument is the Selection name:

{% assign products = 'featured' | products %}
{% for item in products %}
- {{ item.name }}: {{ item.price | currency }}
{% endfor %}

entity - custom entity feed​

Pulls records from a Custom Entity. The first argument is the entity name, the second (optional) is the Selection name:

{% assign episodes = 'tv_series' | entity: 'latest' %}
{% for item in episodes %}
- {{ item.name }}
{% endfor %}

You can pass variables into a parameterized entity Selection - for example, feed it the journey's entry event:

{% assign items = 'products' | entity: 'product_by_id', trigger %}

Notes on how these behave:

  • Selections that are the same for every recipient (no user-attribute or context filters) are cached - about 5 minutes in the editor preview, about a minute on a send - so bulk sends don't re-query per recipient. Personalized entity selections are evaluated fresh for each recipient.
  • If the entity/product Selection can't be found (or the query fails), the filter returns an empty array - your for loop simply renders nothing, never an error.
  • All three filters (products, entity and the catalog alias) run on every render path with the same names: the editor preview, campaign sends, journey sends, and every channel worker. What the preview shows is what the recipient gets.
  • The personalization picker's Product catalog and Custom entity options build the assign snippet for you (see Message Variables).
Deprecated: catalog

The older catalog filter is a deprecated alias of entity and still works, so existing templates don't break. Prefer entity for custom entities and products for the store catalog going forward.

Recommendations​

recommendations.<slot> is an array of recommended products for the recipient, from a recommendation model. The slot is the model name as a key (For you becomes for_you); recommendations.default is the workspace default model. Each item has name, price (formatted), compareAtPrice, discountPercent, url, image, description and id. It works in every channel - email (campaigns and journeys), SMS, WhatsApp, Viber, push, in-app and landing pages - and in editor previews and test sends:

{% for item in recommendations.for_you limit: 3 %}
{{ item.name }} - {{ item.price }} {{ item.url }}
{% endfor %}

See Liquid slots for slot naming and item fields, and the block list for products that are never recommended.

Where Joryio differs from standard Liquid​

A few filters intentionally behave differently from their Shopify/LiquidJS namesakes:

  • capitalize also lowercases the remainder of the string ("mAYA" becomes Maya, not MAYA).
  • default treats an empty string as missing, so {{ user.firstName | default: "there" }} falls back even when the attribute exists but is blank.
  • truncate appends the suffix after the cut length (standard Liquid counts the ellipsis inside the length).
  • where and sort are supersets of the built-ins: they accept the standard call shapes and add comparison operators / sort direction, plus dot-path fields.

Standard Liquid still works​

Everything above is on top of standard LiquidJS: the built-in tags - {% if %} / {% elsif %} / {% else %}, {% for %}, {% assign %}, {% case %} and friends - and the built-in filters (upcase, downcase, date, replace, split, plus, times, and many more) all work in any Joryio template. Where a Joryio filter shares a name with a built-in (capitalize, default, where, sort, join, map, first, last, size), the Joryio version described on this page is the one that runs. See the full built-in list at liquidjs.com/filters/overview.html.