Personalizing campaign messages
Use Liquid variables, fallbacks, filters, and conditional content to personalize every campaign message from your contact data.
Campaign messages support Liquid template variables — placeholders that Beep fills in per recipient at send time, using the contact data you uploaded. One template becomes a different message for every contact:
You write:
Hi {{first_name | default: "friend"}}, the volunteer shift starts Saturday at 9am. Reply YES to confirm.
Your contacts receive:
Hi Maria, the volunteer shift starts Saturday at 9am. Reply YES to confirm.
Hi friend, the volunteer shift starts Saturday at 9am. Reply YES to confirm.
Personalization runs on every campaign start path — campaigns you start immediately from the dashboard, scheduled campaigns, and campaigns started by a connected AI agent. The same rendering engine handles all of them, so a template behaves identically no matter how the campaign launches.
Available variables
Values come from your contact data — the CSV you uploaded or the list the campaign targets.
| Variable | Value | Example output |
|---|---|---|
{{first_name}} | The contact's first name | Maria |
{{last_name}} | The contact's last name | Lopez |
{{full_name}} | First and last name together | Maria Lopez |
{{phone_number}} | The contact's phone number | +15555550123 |
{{your_column}} | Any column you map from a CSV upload (see below) | Your data |
Beyond the standard fields, you can turn your own CSV columns into variables:
- Uploading contacts directly to a campaign? After you upload your CSV, a mapping table shows every column with a destination you choose: keep it as a standard field, save it to a reusable contact field, create a one-off column just for this campaign, or ignore it. Anything you map becomes a variable — a
Shift Timecolumn mapped to a new campaign column gives you{{shift_time}}to use in your message right away. - Importing a list separately? The list import wizard's column-mapping step offers the same choices for any column beyond phone/first/last name. Values you map there save to the contact permanently, so
{{county}}or{{van_id}}are available in every future campaign that targets that list — no need to re-upload the data each time.
Head to Contacts → Custom Fields in the dashboard any time to see and manage your workspace's contact fields — rename a field's label or archive the ones you no longer need.
Composing with variables
The compose step is built to catch problems before you send, not after.
- Insert variable opens a grouped picker — Standard fields, your workspace's Contact fields, and This campaign's columns — so you don't have to remember exact variable names. Picking a contact field or campaign column inserts it pre-wired with a fallback (
{{shift_time | default: ""}}); standard fields insert plain ({{first_name}}) since every contact has them. - Validation as you type checks your message against the variables actually available to this campaign. An unknown variable — a typo like
{{frist_name}}, for example — shows an error with a suggested correction and blocks you from moving past Compose until it's fixed or removed. - Live preview cycles through your real contacts — not just the first row. Step through "Contact 1 of 2,140" and see what each recipient receives, filters and conditionals included. For a list or audience-based campaign, the preview samples the first 500 contacts and reads each contact's stored field values. For a CSV upload, the preview renders from your CSV's data — if a matched contact already has a stored value for a field that isn't a column in this CSV, it will show blank in the preview but still fill in at send time.
- Blank-value warnings call out any variable that's empty for some of your previewed contacts and doesn't have a
default:fallback, with the count affected and a one-click Add fallback button. - A longest rendered message indicator shows the largest segment count seen across your previewed contacts, so you can catch variable-driven cost surprises before you send.
The launch gate
A campaign can't start if its message references a variable that isn't mapped to a real data source — a standard field, an active contact field, or a column mapped on that campaign. This is checked again right before send, not just when you write the message, so if a contact field gets archived after a campaign was created, the campaign won't launch with a template that would now render blank. The error names every unmapped variable so you know exactly what to fix.
Always set a fallback with default
defaultThe single most important pattern. Contact data is rarely complete — some rows won't have a first name. Without a fallback, a missing value is left out, along with the space before it:
Hi {{first_name}}, doors open at 6pm.
→ Hi, doors open at 6pm. (no first name on file)
When the empty variable opens the message, the separator after it is dropped too and the next word is capitalized:
{{first_name}}, doors open at 6pm.
→ Doors open at 6pm.
With default, you choose what missing data looks like:
Hi {{first_name | default: "friend"}}, doors open at 6pm.
→ Hi friend, doors open at 6pm.
Use default on any variable that might be blank in your data. It only kicks in when the value is missing or empty — contacts with real data are unaffected.
Formatting filters
Filters transform a value before it's inserted, using the pipe syntax {{ value | filter }}. Beep supports a fixed set of 15 filters.
Text filters
| Filter | What it does | Example |
|---|---|---|
default | Substitute a fallback when the value is missing | {{first_name | default: "friend"}} → friend |
capitalize | Uppercase the first letter, lowercase the rest | {{first_name | capitalize}} → Maria |
upcase | Uppercase the whole value | {{last_name | upcase}} → LOPEZ |
downcase | Lowercase the whole value | {{last_name | downcase}} → lopez |
strip | Trim leading/trailing whitespace | {{first_name | strip}} → Maria |
truncate | Shorten to a length, ellipsis included | {{last_name | truncate: 10}} → Fitzger... |
Number filters
Useful once numeric fields are in play (and today for any numeric-looking value):
| Filter | What it does | Example |
|---|---|---|
round | Round to the nearest integer | {{ 4.6 | round }} → 5 |
ceil | Round up | {{ 4.2 | ceil }} → 5 |
floor | Round down | {{ 4.8 | floor }} → 4 |
at_least | Clamp to a minimum | {{ 2 | at_least: 5 }} → 5 |
at_most | Clamp to a maximum | {{ 9 | at_most: 5 }} → 5 |
plus | Add | {{ 3 | plus: 2 }} → 5 |
minus | Subtract | {{ 3 | minus: 2 }} → 1 |
times | Multiply | {{ 3 | times: 2 }} → 6 |
divided_by | Divide | {{ 6 | divided_by: 2 }} → 3 |
Filters chain left to right: {{first_name | strip | capitalize | default: "friend"}}.
Only these 15 filters are supported. Any other filter — including a typo like defualt — makes the template invalid, and the campaign won't start until it's fixed. Beep never guesses at what you meant and never sends a half-rendered message.
Conditional content
Show or hide whole phrases based on whether a value is present, with {% if %} / {% elsif %} / {% else %}:
{% if first_name %}Hi {{first_name}},{% else %}Hi there,{% endif %} your appointment is tomorrow at 2pm. Reply C to confirm.
{% unless %} is the inverse — render only when a value is missing:
Reminder: canvass launch Saturday.{% unless first_name %} Reply INFO and we'll get your details on file.{% endunless %}
Both variables and filters work inside conditionals.
What happens when data is missing or a template is broken
Beep is designed so a broken template or a data gap never reaches your contacts as garbled text:
- Missing value → left out together with the space before it (
Hi {{first_name}}, …→Hi, …, neverHi , …); at the start of a message the following separator is dropped and the next word capitalized. Usedefaultwhen you want a word in its place instead. - Unknown variable (a name that doesn't map to any field, live or mapped on this campaign) → the launch gate blocks the campaign from starting at all, naming the unmapped variable(s), so it never reaches recipients as raw or blank text.
- Invalid template (syntax error, unsupported filter or tag) → the campaign fails to start with a clear error before any message is sent. Nothing partially sends.
- Scheduled campaigns with an invalid template are returned to draft instead of sending — fix the message and schedule it again.
Saved responses and API sends
Saved responses use the same engine, filters, conditionals, and missing-value handling as campaign messages. Beep renders a saved response for its recipient wherever it's sent — inserted by an agent in the inbox, sent by a workflow step, sent by a keyword autoresponder, or sent when a connected AI agent applies a workflow. A single send isn't tied to a campaign's uploaded row, so saved responses can use the standard fields and your workspace's active contact fields, but not campaign columns. A saved response that references an unknown variable or uses an unsupported filter or tag is rejected when you save it, and a send whose template can't be rendered is refused rather than delivered with raw {{ }} text.
Messages you send through POST /api/v1/messages are not templated: the body is delivered exactly as you send it, so render any personalization in your own system first. The same is true of replies an agent types directly in the inbox without choosing a saved response.
What's not supported
SMS templates are deliberately simple. These Liquid features are blocked, and using them makes the template invalid:
- Loops (
{% for %},{% tablerow %},{% cycle %}) - Assignments (
{% assign %},{% capture %},{% increment %},{% decrement %}) - Raw blocks (
{% raw %}) and includes/renders/layouts - Filters outside the list above — including misspelled ones; the template is treated as invalid rather than guessing
Composing in the dashboard

The compose step with a {{first_name | default: "friend"}} fallback in the message body.

The preview panel renders your template with sample contact data as you type — filters, fallbacks, and conditionals included — so what you see is what sends.
Common questions
Do variables work with tracked links? Yes. Tracked-link placeholders (the composer's Tracked Link button) and Liquid variables render side by side in the same message.
Does the messages API render variables? No — API sends are delivered exactly as written. See Saved responses and API sends.
Do scheduled campaigns personalize? Yes — rendering happens at send time on every start path: immediate, scheduled, and agent-started.
Do substituted values affect message length? Yes — the rendered value counts toward the message's character count and segment math, and a value containing non-GSM (Unicode) characters can lower the per-segment limit. Budget for your longest realistic values.
Related
- Environments — test vs live keys
- Opt-outs & consent — who is suppressed at send time
Updated 8 days ago