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 is | Does default replace it? |
|---|---|
| Missing entirely | Yes |
| Empty text | Yes |
false | Yes |
The number 0 | No — 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 }} → falseText filters
| Filter | Example | Result |
|---|---|---|
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: 8counts the ellipsis, so you get five characters plus "…", not eight plus "…". Chain it afterupcaseand the count still applies to the whole thing.
Numbers and money
| Filter | Example on 1234.5 | Result |
|---|---|---|
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| Placeholder | Means | Example |
|---|---|---|
%Y / %y | Year | 2026 / 26 |
%B / %b | Month name | March / Mar |
%m | Month number | 03 |
%d | Day of month | 09 |
%A / %a | Day name | Monday / Mon |
%H:%M | Hour and minute, 24h | 15: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 likeupcse. 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:
| Operator | Means |
|---|---|
== / != | Equal to / not equal to |
> < >= <= | Numeric comparison |
contains | Text contains the given text |
and / or | Combine two conditions |
blank | Compare 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
falseare 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
0is 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
defaultfilter 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 wrote | What the recipient sees |
|---|---|
| A variable with no value | Nothing. The surrounding sentence is untouched. |
| A misspelled namespace or path | Nothing — it is indistinguishable from an empty value. |
| A filter that is not allowed | The value, untransformed. |
| A variable Liquid cannot parse at all | Nothing. Every other variable in the email still renders. |
A broken {% %} tag | The tag itself, as visible text. |
| Braces around ordinary words | Exactly 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.