> ## Documentation Index
> Fetch the complete documentation index at: https://developers.conveyour.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Liquid Syntax Guide

> Personalize SMS, email, lessons, and automations with ConveYour's Liquid templating engine.

ConveYour uses **Liquid** — the same templating family popularized by Shopify and many CMSs — almost anywhere you personalize a contact's experience: SMS, email, lesson content, automations, smart links, and more. One mental model, many surfaces.

## Why Liquid

* **Logic + data, not just tokens** — Conditionals, filters, defaults, and composable snippets. Not a fixed mail-merge grid.
* **Composable content** — Pull in shared blocks, nest them, and keep long programs maintainable.
* **Scheduling-aware** — Booking and reschedule links can surface directly in copy when someone has an appointment on file.
* **Integration-friendly** — Pass your own structured data into templates when you compile content via the API so your stack and ConveYour share one language.

***

## Contact fields

**Job to be done:** Greet someone by name, branch on whether a field exists, or fall back gracefully.

**Where:** SMS bodies, email bodies and subjects, lesson text, form acknowledgements, campaign messages — anywhere merge tags are supported.

```liquid theme={null}
Hi {{ contact.first_name | default: "there" }},
```

```liquid theme={null}
{{ contact.email }}
```

Many org-defined fields are available on `contact` and often at the **root** of the template too, so `first_name` and `contact.first_name` can both work — handy when migrating old copy.

***

## SMS

**Job to be done:** Send a punchy text that still feels 1:1.

**Where:** SMS campaigns, automated SMS steps, alerts.

```liquid theme={null}
{{ contact.first_name }}, you're confirmed for tomorrow. Reply HELP for options.
```

```liquid theme={null}
{% if contact.mobile %}
Texting you at {{ contact.mobile }}
{% endif %}
```

***

## Email

**Job to be done:** Match subject lines to body copy; keep both on-brand.

**Where:** Email campaigns, triggered emails, invite and system mail that supports Liquid.

**Subject line:**

```liquid theme={null}
{{ contact.first_name }}, your packet is ready
```

**Body:**

```liquid theme={null}
<p>Hi {{ contact.first_name | default: "there" }},</p>
<p>Your manager is {{ manager_name | default: "the team" }}.</p>
```

***

## Lessons

**Job to be done:** Make lesson content feel like it was written *for this learner* — not a static PDF.

**Where:** Lesson item fields — rich text bodies, poll questions, open-ended prompts, challenge text, and similar fields depending on the block type.

**Poll / question:**

```liquid theme={null}
{{ contact.first_name }}, which topic should we drill next?
```

**Instructional body:**

```liquid theme={null}
Welcome back. Here's what we'll cover today, {{ contact.first_name }}.
```

**Scheduling URL field** — the URL itself can be templated:

```liquid theme={null}
https://cal.example.com/book?name={{ contact.first_name }}%20{{ contact.last_name }}&email={{ contact.email }}
```

***

## Snippets

**Job to be done:** One canonical disclaimer, footer, or compliance block — embedded in SMS, email, and lessons without copy-paste drift.

**Where:** Any Liquid-enabled text. Reference a saved Snippet by its shortcut using function-style syntax.

```liquid theme={null}
snippet(legal.sms_footer)
```

```liquid theme={null}
Hi {{ contact.first_name }},

snippet(program.intro)

Reply STOP to opt out.
```

Snippets **nest**: a snippet can call another snippet, and each one is itself compiled (so a nested snippet's own Liquid runs too).

<Warning>
  The limit is a **total of 6 snippet expansions per compile**, not a nesting depth. The counter is shared across the whole template, so six *side-by-side* snippets exhaust it just as fast as six nested ones. Expansions beyond the sixth resolve to an **empty string** — silently, with no error.

  If a footer or disclaimer mysteriously renders blank, count the `snippet(...)` calls in the whole message, including any inside other snippets.
</Warning>

The shortcut is a bare name of word characters, dots, and underscores (e.g. `snippet(program.intro)`), and surrounding whitespace inside the parentheses is allowed. Snippets resolve *after* Liquid renders.

***

## Scheduling links

**Job to be done:** Confirmations and reminders that include action links tied to the learner's latest booking.

**Where:** SMS, email, or lesson copy when referencing booking data for a specific contact field (e.g. the field used for "interview" or "onboarding session").

```liquid theme={null}
Need to move your session? Reschedule here: {{ contact.bookings.interview.reschedule_url }}
```

```liquid theme={null}
Cancel: {{ contact.bookings.interview.cancel_url }}
```

Replace `interview` with the **contact field name** your org uses for that booking. If there is no booking, pair with `default` or an `{% if %}` check.

***

## Custom Object Relations

**Job to be done:** Traverse relationships between custom object records inside Liquid, so templates can pull nested fields without extra API work.

**Where:** Anywhere Liquid is supported (SMS, email, lessons, snippets, triggered messages), as long as your org has added **Relation** fields to custom object schemas.

**Path shape:**

```liquid theme={null}
contact.relations.<contact_custom_object_field>.<relation_field>.<nested_field>
```

**Example — division → region → name:**

```liquid theme={null}
contact.relations.division.region.name
```

<Note>
  Dotted paths that cross a Relation field may resolve to actual related-record data instead of raw IDs. Be intentional about which fields you expose in outbound messages.
</Note>

***

## Query custom object records

**Job to be done:** Pull a filtered, sorted set of custom object records straight into a template — no extra API call.

**Where:** Anywhere Liquid runs, when your org has a [Custom Object](/api-reference/custom-objects/list-custom-objects) defined.

Start from `customObject` (by the object's **name**), narrow with `where` + a comparison, optionally `sort`, then `get` to execute:

```liquid theme={null}
{% assign recent = 'vehicles' | customObject | where: 'year' | gte: 2022 | sortDesc: 'year' | get: 5 %}
{% for v in recent %}
- {{ v.make }} {{ v.model }} ({{ v.year }})
{% endfor %}
```

The chain is built from these filters:

| Step | Purpose |
| - | - |
| `'name' \| customObject` | Resolve the object by name; returns a query scope to chain on |
| `where: 'field'` | Target a field for the next comparison |
| `lt` / `lte` / `eq` / `gte` / `gt` | Comparison applied to the `where` field (e.g. `where: 'year' \| gt: 2020`) |
| `sort: 'field'` / `sortDesc: 'field'` | Order ascending / descending |
| `get` or `get: N` | Execute and return the rows (default limit `10`) |

Use your schema **field names** in `where` and `sort` — the engine maps them to storage paths automatically. An unknown object name yields an empty result set, so templates degrade gracefully.

***

## Contact variables

**Job to be done:** Read and manipulate small structured values stored on the contact — scores, flags, JSON blobs — without dumping raw JSON in the message.

**Where:** Advanced templates across channels.

Read a stored value with `grab`:

```liquid theme={null}
{{ contact.vars | grab: 'loyalty_tier' | default: 'standard' }}
```

Write or remove one with `store` and `drop`:

```liquid theme={null}
{{ contact.vars | store: 'loyalty_tier', 'gold' }}
{{ contact.vars | drop: 'loyalty_tier' }}
```

`contact.vars` resolves to a per-contact pointer, so these filters read and write values scoped to that single contact.

***

## Bring your own data (API compile)

**Job to be done:** Your app already knows something ConveYour does not store — order ID, cohort, external case number — and you want it inside the template at send time.

**Where:** Flows that call ConveYour's **content compile** capability. Pass a template string plus a JSON `inputs` object. Your keys show up on the template root as `inputs`.

```liquid theme={null}
Order {{ inputs.order_id }} is ready. Questions? Reference {{ inputs.case_code }}.
```

You also get context about the **authenticated user** where the platform provides it:

```liquid theme={null}
— {{ user.first_name }} {{ user.last_name }}, your coach
```

***

## Filters

### Default when empty

```liquid theme={null}
{{ contact.nickname | default: contact.first_name | default: "friend" }}
```

### Passwords / one-time tokens

`generatePassword` ignores whatever is piped into it — pipe an empty string and pass the length:

```liquid theme={null}
Your temporary password: {{ '' | generatePassword: 16 }}
```

The argument is the length (defaults to `8` if omitted).

### Time and timezone

`tz` converts a datetime to a timezone (returning an ISO-8601 string); `formatTime` renders any datetime with a PHP [`date()`](https://www.php.net/manual/en/datetime.format.php) format string. Chain them to localize and format in one pass:

```liquid theme={null}
{{ contact.schedule_date | tz: "America/Chicago" | formatTime: "g:ia" }}
```

```liquid theme={null}
{{ contact.schedule_date | formatTime: "Y-m-d H:i" }}
```

Pass the special format `"unix"` to get a timestamp back: `{{ contact.schedule_date | formatTime: "unix" }}`.

Two convenience filters build on the same date handling:

```liquid theme={null}
Your session is on a {{ contact.schedule_date | dayOfWeek }} — the {{ contact.schedule_date | dayOfMonth }}.
```

`dayOfWeek` returns the weekday name (`Monday`); `dayOfMonth` returns the day with its ordinal (`3rd`). Replace `schedule_date` with your org's field name throughout.

### Extract a list

`list` pulls one key out of each item in an array (or a comma-separated string) and joins the values with commas. Defaults to the `id` key:

```liquid theme={null}
{{ contact.campaigns | list: 'id' }}
```

### Signed tokens (JWT)

`jwtToken` mints an org-signed JWT — the first argument is the expiration, followed by key/value pairs to embed in the payload:

```liquid theme={null}
{{ '+1 hour' | jwtToken: 'contact_id', contact.id, 'role', 'guest' }}
```

<Note>
  `jwtToken` only produces a token when your org has its own **JWT secret key** configured (otherwise it returns an empty string) — it's meant for signing links your own systems will verify. Ask your ConveYour team if this is set up for you.
</Note>

***

## Validate and normalize data

**Job to be done:** Clean up messy contact data, check validity, and branch in the template — or collect a list of errors before sending a payload to an integration.

Each field passed through `format` returns an object with three properties:

| Property | Description |
| - | - |
| `valid` | `true` when the value passes validation |
| `value` | The cleaned, normalized value |
| `error` | Human-readable reason when validation fails (empty when valid) |

**Single field example:**

```liquid theme={null}
{% assign emailResult = contact.email | format: 'email' %}
{% if emailResult.valid %}
  Send to: {{ emailResult.value }}
{% else %}
  Fix email: {{ emailResult.error }}
{% endif %}
```

**Discover available formats:**

```liquid theme={null}
{% assign formats = '' | formatList %}
```

### Supported formats

| Format key | What it checks |
| - | - |
| `email` | Trims whitespace, lowercases, validates email syntax |
| `us_bank_routing_number` | 9 digits + ABA routing checksum |
| `us_bank_account_number` | 4–17 digits (ACH-safe length) |
| `us_ssn` | 9-digit SSN structure (excludes known-invalid SSA ranges) |
| `us_zip_code` | 5-digit US ZIP (leading zeros preserved) |
| `us_state_abbreviation` | Two-letter state code or full state name → uppercase abbreviation |

### Pre-flight validation

Validate several fields at once and output either `valid` or a list of failures — ideal before pushing data to an external system:

```liquid theme={null}
{% assign errors = '' %}

{% assign emailResult = contact.email | format: 'email' %}
{% unless emailResult.valid %}{% capture errors %}{{ errors }}- Email: {{ emailResult.error }}
{% endcapture %}{% endunless %}

{% assign routingResult = contact.bank_routing_number | format: 'us_bank_routing_number' %}
{% unless routingResult.valid %}{% capture errors %}{{ errors }}- Bank routing number: {{ routingResult.error }}
{% endcapture %}{% endunless %}

{% assign accountResult = contact.bank_account_number | format: 'us_bank_account_number' %}
{% unless accountResult.valid %}{% capture errors %}{{ errors }}- Bank account number: {{ accountResult.error }}
{% endcapture %}{% endunless %}

{% assign ssnResult = contact.DECRYPTED_ssn | format: 'us_ssn' %}
{% unless ssnResult.valid %}{% capture errors %}{{ errors }}- SSN: {{ ssnResult.error }}
{% endcapture %}{% endunless %}

{% assign zipResult = contact.zip | format: 'us_zip_code' %}
{% unless zipResult.valid %}{% capture errors %}{{ errors }}- ZIP code: {{ zipResult.error }}
{% endcapture %}{% endunless %}

{% if errors == '' %}
valid
{% else %}
{{ errors | strip }}
{% endif %}
```

When everything passes, output is simply `valid`. When something fails:

```
- Email: Email address is invalid
- Bank routing number: US bank routing number failed checksum validation
```

<Warning>
  `format` is a **structure and sanity check** — it catches typos, wrong lengths, and invalid patterns. It does **not** verify that a bank account is open, that an SSN belongs to the contact, or that an email inbox exists. For integrations requiring proof of account ownership, pair template validation with your provider's verification flow.
</Warning>

***

## Plugin extras

Some capabilities are add-ons or plugins. When enabled, you get additional Liquid filters — same template language, more superpowers.

**Magic links** (personalized short links into flows):

```liquid theme={null}
{{ contact | magic_link: 'your-link-slug' }}
```

**Waiting rooms** (JWT-backed room URLs):

```liquid theme={null}
{{ contact | waitingRoomUrl: 'room-slug' }}
```

Ask your ConveYour team what is enabled for your workspace — each plugin can register its own filters, and some orgs have bespoke ones built for their workflow.

***

## Standard Liquid is available too

Everything above is ConveYour's *custom* filters. The full standard Liquid vocabulary works alongside them — the tags you've seen in these examples (`assign`, `capture`, `if`/`unless`, `for`, `case`) plus `cycle`, `tablerow`, `paginate`, `increment`/`decrement`, `raw`, and `comment`, as well as standard filters like `upcase`, `downcase`, `date`, `size`, and `join`.

<Note>
  ConveYour does **not** add custom *tags* — only custom *filters* (and the `snippet(...)` shortcut). So if a capability isn't a standard Liquid tag, it's a custom filter — the common ones are documented above.
</Note>

## Design principles

1. **Templates travel** — Skills you build for SMS transfer to lessons and email.
2. **Composition beats duplication** — Snippets and filters exist so programs scale past a handful of campaigns.
3. **Integration-friendly** — Liquid is widely understood; pairing it with `inputs` from your systems keeps ConveYour from becoming a walled garden.

## What to do next

* Prototype a lesson block with `{{ contact.first_name }}` and a `snippet(...)` call — then reuse that snippet in an SMS.
* If you integrate with the ConveYour APIs, exercise **compile** with `inputs` and confirm your keys resolve in Liquid.
* Ask your org admin which plugins (magic links, waiting rooms, etc.) are live — those unlock extra filters.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.