Liquid syntax

Spreeflo renders your copy with Liquid, the same templating language Shopify uses. It is what turns Hi {{ contact.first_name }} into Hi Rico for one recipient and Hi Ada for the next.

This page is the reference: filters, conditions, formatting, and what happens when something is written wrongly. For which variables exist and where their values come from, see Personalize with AI variables and Send transactional emails.

Liquid works everywhere copy is personalised: email subject lines, preview text and bodies, web push titles and bodies, and raw messages sent through the API.

Variables

A variable is a namespace and a path inside double braces. The namespace says where the value comes from — {{ contact.first_name }}, {{ order.total }}, {{ spree.unsubscribe_url }}.

Spaces inside the braces are optional, so these are the same thing:

{{ contact.first_name }}
{{contact.first_name}}

A variable that has no value renders as nothing at all. That is not an error — the rest of the sentence is untouched — but it is why default below matters.

Filters

A filter transforms a value on its way into the copy. Add one with |, pass arguments after a colon, and chain as many as you like — each one receives the output of the one before it.

{{ contact.first_name | upcase }}
{{ order.total | currency: "GBP" }}
{{ contact.first_name | default: "there" | upcase }}

A chain can start from a fixed value rather than a variable, which is occasionally handy — {{ "now" | date: "%Y" }} prints the current year, and {{ 5 | plus: 1 }} prints 6. Examples from Shopify's Liquid documentation work here unchanged.

Fallbacks with default

default is the filter you will use most. It supplies a value when the variable has none, which is what keeps "Hi ," out of your emails.

Hi {{ contact.first_name | default: "there" }},

Your {{ contact.plan_tier | default: "current" }} plan renews soon.

It is worth knowing exactly when it steps in:

The value isDoes default replace it?
Missing entirelyYes
Empty textYes
falseYes
The number 0No — 0 is a real value and is printed

That third row matters for yes/no values. A custom object attribute that is genuinely false — an order that is not a gift, a subscription that has not renewed — gets replaced by the fallback, which is rarely what you want. Add allow_false: true to keep it:

{{ order.is_gift | default: "unknown" }}                      →  unknown
{{ order.is_gift | default: "unknown", allow_false: true }}  →  false

Text filters

FilterExampleResult
upcase{{ c.name | upcase }}ADA LOVELACE
downcase{{ c.name | downcase }}ada lovelace
capitalize{{ c.name | capitalize }}Ada lovelace
truncate{{ c.name | truncate: 8 }}ada l…
truncatewords{{ c.name | truncatewords: 1 }}ada…
replace{{ c.name | replace: "ada", "Ada" }}Ada lovelace
append{{ c.name | append: "!" }}ada lovelace!
prepend{{ c.name | prepend: "Dr " }}Dr ada lovelace
strip{{ c.name | strip }}Removes surrounding whitespace
url_encode{{ c.name | url_encode }}ada+lovelace — for values you put in a link

truncate: 8 counts the ellipsis, so you get five characters plus "…", not eight plus "…". Chain it after upcase and the count still applies to the whole thing.

Numbers and money

FilterExample on 1234.5Result
currency{{ order.total | currency }}$1,234.50
currency with a code{{ order.total | currency: "EUR" }}€1,234.50
round{{ order.total | round }}1235
round to decimals{{ order.total | round: 1 }}1234.5
ceil / floor{{ order.total | floor }}1234
plus / minus{{ order.qty | plus: 2 }}Arithmetic on the value
times / divided_by{{ order.total | times: 2 }}2469
at_least / at_most{{ order.qty | at_least: 1 }}Clamps to a floor or ceiling
abs{{ balance | abs }}Drops the minus sign

currency is Spreeflo's own filter and the one to reach for with money. It groups thousands, fixes the decimals, and adds the symbol. Pass any ISO currency code; leave it out and you get USD. If the value is not a number it is returned untouched rather than mangled.

Dates

date formats a stored date using the placeholders below.

{{ contact.created_at | date: "%B %d, %Y" }}   →  March 09, 2026
{{ contact.created_at | date: "%d/%m/%Y" }}     →  09/03/2026
PlaceholderMeansExample
%Y / %yYear2026 / 26
%B / %bMonth nameMarch / Mar
%mMonth number03
%dDay of month09
%A / %aDay nameMonday / Mon
%H:%MHour and minute, 24h15:04

Filters that are not on the list

Spreeflo allows the filters documented above and no others. The full set is: default, date, upcase, downcase, capitalize, truncate, truncatewords, strip, replace, append, prepend, plus, minus, times, divided_by, round, ceil, floor, abs, at_least, at_most, url_encode and currency.

Anything else is ignored silently. Write {{ contact.first_name | reverse }} and you get the name back unchanged — no error, no empty space, just the untransformed value. The same is true of a typo like upcse. If a filter appears to do nothing, check it against the list above first.

Conditions and loops

Beyond variables, Liquid has tags — written with {% %} — for showing a section only when something is true.

{% if contact.plan_tier == "pro" %}
  Thanks for being a Pro member.
{% else %}
  Upgrade any time for more.
{% endif %}

Conditions accept these operators:

OperatorMeans
== / !=Equal to / not equal to
> < >= <=Numeric comparison
containsText contains the given text
and / orCombine two conditions
blankCompare against "empty", e.g. {% if contact.company == blank %}

{% unless %} is the inverse of {% if %}, {% elsif %} adds further branches, and {% comment %} hides a note from the reader. {% for %} repeats a section.

In Liquid, only a missing value and false are false. Empty text and the number zero both count as true — which catches people out, because a contact who left their company blank would still pass {% if contact.company %} on a plain reading of the rules.

Spreeflo takes that edge off: a value that resolves to nothing is left out of the message entirely rather than passed along as empty text. So {% if contact.company %} does read as false for a contact who never filled it in, and the same holds for object attributes, system values, and AI copy that was skipped. Conditions behave the way you would expect them to.

Two things still follow the raw Liquid rule. The number 0 is a real value and reads as true. And a variable you send through the API as an empty string counts as supplied, because you said so explicitly. Write {% if var.code != blank %} when that distinction matters.

Note that the default filter takes the opposite view of empty text and does step in for it. That is standard Liquid rather than anything specific to Spreeflo, so what you know from Shopify or any other Liquid reference applies here unchanged.

Unlike variables, tags are not checked when you save. A mistyped variable is caught in the editor; a mistyped tag is not, and it will appear as raw text in the delivered message. Send yourself a test whenever you add one.

When something is wrong

A broken template never stops an email going out. Spreeflo isolates the damage to the smallest piece it can, which means the failure modes are worth recognising:

What you wroteWhat the recipient sees
A variable with no valueNothing. The surrounding sentence is untouched.
A misspelled namespace or pathNothing — it is indistinguishable from an empty value.
A filter that is not allowedThe value, untransformed.
A variable Liquid cannot parse at allNothing. Every other variable in the email still renders.
A broken {% %} tagThe tag itself, as visible text.
Braces around ordinary wordsExactly what you typed — see below.

The first two rows are why a blank space in a test send is worth chasing down: an empty value and a typo look identical in the delivered email.

Showing braces as text

Double braces around ordinary words are left alone, so writing {{ click here }} or {{ 50% off }} in your copy is safe — they reach the inbox unchanged. Spreeflo only evaluates what actually looks like Liquid: a value followed by optional filters.

To show something that would otherwise be evaluated — documentation about Liquid itself, say, or a literal {{ "quoted string" }} — wrap it in {% raw %}:

{% raw %}{{ contact.first_name }}{% endraw %}

HTML and Liquid

Values are inserted exactly as they are stored, without HTML escaping. That is deliberate — it lets a value hold markup when you need it — but it does mean a stray < in a contact attribute becomes part of the page. Keep untrusted values out of places where markup would matter.

Quotation marks inside a filter argument sometimes get rewritten by rich text editors into typographic quotes or HTML entities. Spreeflo repairs those automatically before rendering, so {{ contact.first_name | default: “there” }} still works — but straight quotes are safer to type.