Loading tutorials…
Loading tutorials…
Liquid is what turns Customer.io from 'mass blast tool' into 'one-to-one personalization at scale.' Knowing the right 20% of Liquid syntax — variables, defaults, conditionals, filters — covers 95% of SaaS personalization use cases.
Who this is forCustomer.io users who've gone beyond 'hi {{ first_name }}' personalization and want to use real attribute-based logic in emails, SMS, push, and in-app messages. Or marketers who keep breaking templates with missing-attribute errors.
What you'll need
Step 1
Customer.io makes 4 objects available in any template: customer, event, content, workspace. Memorize these.
**`customer`**: the recipient profile. Access any attribute via `{{ customer.attribute_name }}`. Examples: `{{ customer.first_name }}`, `{{ customer.plan }}`, `{{ customer.mrr_cents }}`, `{{ customer.team_size }}`.
**`event`**: when the template is fired by a track event, the event's properties are available. Examples: `{{ event.plan_name }}`, `{{ event.amount_cents }}`, `{{ event.invite_link }}`.
**`content`**: campaign-level context. `{{ content.subject }}`, `{{ content.preheader }}`. Useful for unifying subject lines with body content.
**`workspace`**: workspace-level vars set in Settings. Useful for brand-wide variables like `{{ workspace.brand_name }}`, `{{ workspace.support_email }}`.
Other built-ins: `{{ now }}` (current timestamp), `{{ today }}` (current date), `{{ url }}` (preview URL for in-browser view of email).
Step 2
Every customer attribute could be missing for some profiles. Use `| default: 'fallback'` to prevent broken-looking emails.
Bad: `Hi {{ customer.first_name }},` — if first_name is missing, this becomes `Hi ,`.
Good: `Hi {{ customer.first_name | default: 'there' }},` — falls back to 'Hi there,' when missing.
Pattern this everywhere. Subject lines especially — broken subjects look worse than broken body.
For numeric variables: `{{ customer.team_size | default: 1 }}` — fallback to 1 if missing.
For dates: `{{ customer.trial_ends_at | default: "soon" }}` — gives natural language fallback when date is missing.
Quick audit: open any template, search for `{{ customer.` and `{{ event.`. Every instance should have a `default` filter or be inside a conditional. No exceptions.
Step 3
Liquid `{% if %}` blocks let you show/hide content based on user attributes. The biggest personalization unlock.
Basic: `{% if customer.plan == 'Pro' %} You're on Pro. {% else %} Upgrade to Pro for more. {% endif %}`
Multi-branch: `{% if customer.team_size > 10 %} Enterprise tier {% elsif customer.team_size > 1 %} Team tier {% else %} Solo {% endif %}`.
Combined conditions: `{% if customer.plan == 'Pro' and customer.mrr_cents > 10000 %} ... {% endif %}`.
Negative checks: `{% unless customer.email_verified %} Please verify your email. {% endunless %}`.
Existence checks: `{% if customer.first_name %} Hi {{ customer.first_name }} {% else %} Hi there {% endif %}` — safer than default in some cases.
Use conditionals to ship one email that personalizes 4-6 ways, instead of building 6 separate emails.
Step 4
When customer has a list of items (last_3_invoices, top_used_features, team_members), `{% for %}` loops render each.
`{% for invoice in customer.last_3_invoices %}`
`{{ invoice.date }}: $${{ invoice.amount_dollars }}`
`{% endfor %}`
Loops work with event arrays too: `{% for item in event.line_items %} {{ item.name }} - {{ item.qty }}x {% endfor %}`.
Loop variables: `{{ forloop.index }}` (1-based), `{{ forloop.first }}`, `{{ forloop.last }}`. Useful for "1, 2, and 3" formatting.
Limit loops: `{% for x in arr limit:5 %}` shows only first 5 items.
Use case: weekly digest email iterating over last week's usage stats. Receipt email iterating over line items. Power-user email iterating over top 3 features.
Step 5
Liquid filters transform variable output. Date formatting, currency, string manipulation — covers 90% of formatting needs.
**Date formatting**: `{{ customer.created_at | date: "%B %d, %Y" }}` → "May 26, 2026". Other formats: "%A" (Monday), "%I:%M %p" (3:45 PM).
**Currency**: Customer.io stores money as cents typically. `{{ event.amount_cents | divided_by: 100 | round: 2 }}` → 19.99. Then prefix with `$` in the template.
**String manipulation**: `{{ customer.first_name | capitalize }}` → first letter uppercase. `{{ customer.email | downcase }}` → lowercase. `{{ customer.full_name | upcase }}` → uppercase.
**Truncation**: `{{ event.product_name | truncate: 30 }}` → adds "..." after 30 chars.
**Chains**: filters chain with `|`. `{{ customer.first_name | default: 'Friend' | capitalize }}`.
**Math**: `{{ customer.team_size | plus: 1 }}`, `{{ event.amount_cents | times: 1.1 }}` (10% markup), `{{ customer.mrr_cents | divided_by: 100 }}`.
Step 6
Subject lines are visible in the inbox before opening. Broken Liquid here is the most damaging.
Bad: `{{ customer.first_name }}, your team needs you` — becomes `, your team needs you` if first_name missing.
Good: `{% if customer.first_name %}{{ customer.first_name }}, y{% else %}Y{% endif %}our team needs you` — becomes `Your team needs you` cleanly when first_name missing.
Test EVERY subject line by rendering with a profile that has ALL attributes set, then a profile with NONE set. Both should look natural.
Preheader (preview text) follows the same rules. `{% if customer.usage_count > 0 %}You used X feature N times last week{% else %}Here's a week of product updates{% endif %}`.
Customer.io UI has a "Preview with sample data" — use it. Switch sample profiles to test edge cases.
Step 7
SMS, push, in-app messages, and even segments support Liquid. Same patterns apply.
**SMS**: 160 chars matters. Use Liquid for personalization but keep templates tight. `Hi {{ customer.first_name | default: 'there' }}, your trial ends {{ customer.trial_ends_at | date: '%a' }} — upgrade: {{ workspace.upgrade_url }}` fits under 160.
**Push**: same Liquid syntax in title + body. Title under 50 chars; body under 150. Use defaults aggressively.
**In-app**: full Liquid support in titles, body, button labels, CTA URLs. Combine with conditionals to render different in-app messages from one template.
**Segments**: data-driven segments can use Liquid-like operators (`customer.plan == 'Pro'`). Not full Liquid, but the operator syntax is similar.
**Transactional template variables**: passed via the Send Transactional API JSON body. Reference as `{{ trigger.variable_name }}` typically (Customer.io syntax varies; check current docs).
Common mistakes
No default filters — broken-looking emails on missing attributes
What goes wrong: 20-40% of profiles missing some attribute (first_name, plan, team_size). Their emails render with empty spaces ('Hi ,'). Open-to-click rate drops 15-30% on those segments because the email looks broken/spammy.
How to avoid: Audit every `{{ customer. }}` and `{{ event. }}` reference. Add `| default:` to every one. 30-minute audit per workspace.
Subject line with raw variable, no fallback
What goes wrong: Subject reads 'Hi , your account update' in 30% of inboxes. Reads as spam. Open rate halves for those recipients. Spam filter score worsens.
How to avoid: Use conditional wrap for subject variables: `{% if customer.first_name %}{{ customer.first_name }}, y{% else %}Y{% endif %}our account update`. Tedious but bulletproof.
Hardcoded brand strings in every template
What goes wrong: Brand name change, product rename, support email update — requires editing 50+ templates. Half get missed. Inconsistent branding for months.
How to avoid: Set workspace-level variables in Settings → Custom Liquid Variables. Reference as `{{ workspace.brand_name }}` everywhere. Update once, propagates everywhere.
Event property used in template but not always present
What goes wrong: Email triggered by both `Subscription Started` and `Subscription Renewed` events. Template uses `{{ event.discount_code }}` which only exists on Started. Renewal emails render `Use code to save` — broken.
How to avoid: Wrap event-specific properties in conditionals: `{% if event.discount_code %}Use code {{ event.discount_code }} to save{% endif %}`. Or split into two templates by event source.
Date formatted without timezone awareness
What goes wrong: User in Tokyo sees email referencing 'trial ends Tuesday' that's actually Monday in their timezone. Confusion + missed cancellations.
How to avoid: Use Customer.io's timezone-aware date filter: `{{ customer.trial_ends_at | date_in_zone: customer.timezone }}`. Requires storing timezone on profile (collect via browser detection on signup).
Numeric attributes formatted as cents but displayed as dollars
What goes wrong: Email says 'Your subscription is $1999.00/month' (because MRR stored as 199900 cents, divided by 100 wrong). Confusing-to-laughable. Trust damage.
How to avoid: Pick one convention (always cents in storage, always converted in templates). `{{ customer.mrr_cents | divided_by: 100 | round: 2 }}` and prefix `$`. Document in your event schema.
Recap
Done — what's next
How to build Customer.io email campaigns that ship clean and deliver
Read the next tutorial
Hand it off
Liquid templating mastery is the difference between 'sending emails' and 'sending personalized lifecycle programs.' A specialist who's templated 100+ SaaS campaigns will build a defensive template library, a workspace-variable system, and a test-data harness in 1 week — typically $800-1,800 at $14-16/hr. Pays back in eliminated broken-email incidents alone.
See specialist rates
Same core engine, different object model. Shopify exposes `cart`, `product`, `collection` — Customer.io exposes `customer`, `event`, `content`, `workspace`. Filter syntax is identical (date, capitalize, default, etc.). If you know Shopify Liquid, Customer.io Liquid is a 1-day learning curve to map the object model.
Not directly. Customer.io provides a fixed set of built-in filters. For custom logic, compute the derived value in your backend and store as a profile attribute or pass as event property. E.g., instead of writing a custom 'days_until' filter, compute `days_until_trial_end` server-side and store on the profile.
Customer.io UI → People → click a profile → Activity → click a sent message → 'View Rendered Content' shows the rendered template with that profile's actual data. Reveals where Liquid broke. For systematic debugging, send a test broadcast to yourself with a known-edge-case profile.
Limited. Data-driven segments use a filter-builder UI with comparison operators, not full Liquid. For complex segment logic, compute it as a profile attribute server-side (via your backend or via reverse ETL from your warehouse) and segment on the attribute.
Both render as empty string by default. `{{ customer.nonexistent }}` renders as `` (empty). `{% if customer.nonexistent %}` is falsy. Use `| default:` for safe rendering. Use `{% if customer.attr %}` for conditional rendering when missing should mean skip-the-block.
Yes — Customer.io processes Liquid throughout the email HTML, including <head>. Useful for setting dynamic meta description, custom CSS based on user plan, etc. Be careful with quotes/escaping in HTML attributes.
Customer.io
Customer.io's email side has three distinct surfaces — Broadcasts, Newsletters, and Transactional — and each has its own rules. Mix them up and you'll send marketing content from your transactional IP (bad) or transactional from your marketing IP (worse).
Customer.io
Segments are how Customer.io decides who. Workflows are how it decides when and what. Most SaaS teams get one or the other right but rarely both — and the gap shows up as activation campaigns missing 40% of eligible users.
Customer.io
Customer.io is only as smart as the events you send it. Sloppy instrumentation — events fired client-side, inconsistent naming, missing properties — silently sabotages every workflow you build on top. This is the schema that scales.
Customer.io
Email + SMS + Push is the canonical SaaS lifecycle trifecta. Adding SMS lifts onboarding completion 8-15%. Adding push lifts re-engagement on mobile apps 20-35%. The setup is one weekend of work, ongoing.
Customer.io
In-app messaging closes the gap email can't reach — the user who is actively using your product right now. Banners, modals, slideouts, and inline messages reach users at the exact moment of intent.
Customer.io
DIY Customer.io is the right call — until it isn't. In healthy SaaS, lifecycle email + in-app should drive 20-35% of activation and 10-20% of retention. If yours is at 5-10%, the gap is the program isn't being worked. Here's the honest framework for when to hire.