One feature, three names. These docs and the contacts API call them merge tags. The automation surface calls the same thing variables (a trigger-payload variable, a body token), and the app’s field chooser is the variable picker. They all mean
{{ fieldName }}.Syntax
A merge tag is a field name inside double braces.
Spacing inside the braces doesn’t matter:
{{firstName}} resolves exactly like {{ firstName }}, with or without a fallback. These docs write the spaced form because it’s easier to read.
Logic, Loops, and Filters
Those two forms are the common case, not the limit. Brew renders Liquid, so conditionals, loops, and filters work in any email:Where They Work
- Email body
- Subject line
- Preview text
fromNameandreplyTo, on automation sends- Filter and Split node conditions, which reference trigger payload fields by name
subject and previewText on a sendEmail node take the same syntax. They are evaluated against the trigger payload at fire time.
The surfaces fail differently when a field is missing. An unresolvable tag in the body, subject, preview text, fromName, or replyTo renders empty and the send goes out.
An unresolvable reference in a Filter or Split condition blocks the automation from publishing. A condition that can’t evaluate can’t route contacts.
What You Can Reference
Contact Properties
Every contact carries three default properties that resolve as merge tags.subscribed, created_at, and updated_at are filter and sort fields rather than merge tags. Brew keeps them out of the variable picker because they are bookkeeping. If you want a date in email copy, store it as a custom property.
Manage Contacts owns the full contact field list and the reserved names you can’t use for custom properties.
Custom Properties
Any custom property resolves by its name, so a property namedplan is {{ plan }}. Custom properties are the way to personalize on anything Brew doesn’t track by default.
Trigger Payload Fields
In an automation, every field declared on the trigger event resolves by name. An event carryingorderId gives you {{ orderId }}.
A payload field can be an object with its own fields. Declare the nesting on the trigger and each leaf becomes addressable by its dotted path ({{ user.plan }}) and appears in the variable picker. Undeclared keys are reported as warnings on the fire and never reach the template, at any depth.
When a Tag Does Not Resolve
An unresolved tag renders as empty rather than as an error, and the email still sends. That makes a typo silent, so it’s worth checking a tag before a large send. Three things cause this:The name does not match
The name does not match
Names are case-sensitive and must match the declared field exactly.
{{ firstname }} does not match a field named firstName.The field is not declared on the event
The field is not declared on the event
Some fields exist only on certain events from a provider. Stripe’s
invoiceNumber, for example, is declared on invoice.created, invoice.paid, and invoice.payment_failed, but not on invoice.upcoming.The field is internal
The field is internal
Brew strips some provider fields before an automation sees them, so they never resolve even though the provider sends them. Stripe’s opaque object IDs work this way. The Stripe customer ID still reaches the contact as the
stripe_customer_id custom field, so filter on it there.Hi {{ firstName }}, becomes Hi , for a contact with no name. Hi {{ firstName | there }}, reads correctly either way.
The failure mode is the same whatever domain the send goes out from, including a transactional-purpose one: an unresolved tag renders empty, a template syntax error fails loudly. Verify both with a test send before wiring production traffic.
Liquid Templating
Brew renders Liquid, the same engine Customer.io runs, for every workspace. There is nothing to enable and no second templating mode: merge tags and Liquid are one language, so the two forms above and real logic live in the same template. See Migrate From Customer.io if you are bringing templates across.{% assign %}, {% case %}, and the rest of standard Liquid all render at send time, per recipient. Two tags are deliberately disabled: {% include %} and {% render %} (file inclusion), which fail the template loudly rather than being ignored.
Namespaces
Nested Payloads
Trigger fires accept nested JSON aspayload, arrays and objects included, and templates address it by path:
"firstName": "Ada" still fills {{ firstName }}. A test: true send renders exactly like the live fire, so your test tells the truth about it.
Don’t guess the payload shape. The variable tree comes from the trigger’s payload contract: GET /v1/automations/triggers?triggerEventId=… returns the trigger row with its payloadSchema, the exact shape every fire is validated against, nesting included. A GET on the fire endpoint itself (the ready check) returns the same contract plus what a fire would start, without firing. The same contract is in the app on the trigger’s page, in the TypeScript SDK as brew.automations.triggers.list({ triggerEventId }), and over MCP as list_triggers and check_trigger_ready. To turn the contract into TypeScript types in your codebase, with a CI drift gate and typed SDK calls, see Typed payload contracts; add ?include=skill to the trigger read for a complete SKILL.md wiring brief.
Both Syntaxes Coexist
{{ firstName | there }} is a merge tag with a fallback; {{ price | currency }} is a Liquid filter. Brew tells them apart by whether every pipe segment names a real filter, and rewrites the fallback form to Liquid’s | default: before rendering. Existing templates keep rendering byte for byte.
Coming From Another Platform
Email sends have no per-send data payload: they take an audience or a list of recipients, and data reaches the email one of two ways:
So the Liquid-style question “how do I pass variables with the send” becomes “is this about the person or about the event?” Store it on the contact, or declare it on the trigger and send it when you fire the event. Build an Automation walks through declaring a payload, and Add Contacts covers getting properties onto contacts in the first place.
The trigger fire is also the direct analogue for per-send data:
POST /v1/automations/triggers/{triggerEventId}/fire carries a payload exactly like Customer.io’s message_data, nested JSON included. A transactional email in Brew is exactly this, an automation whose send step uses a transactional-purpose sending domain, fired per recipient with the moment’s data. See Nested Payloads above and Emails vs Automations.
Check Before You Send
Send yourself a test and confirm each tag resolves, and that the fallback reads naturally when it does not. QA an Email Before a Big Send covers the wider pre-send routine. For automations there is a machine check too. Publish withdryRun: true and the response’s blockingIssues[] names every unresolvable reference, with its node, surface, and variable.
Fatal issues (conditions, triple braces) block publish; advisory ones (subject, preview text, body tags) would render empty. Run it after swapping a trigger or removing a payload field, which is exactly when references break.
Personalizing on a field most of your contacts have not filled in means most of them
see the fallback.
GET /v1/fields?include=coverage reports the percentage of contacts
holding a non-empty value for each field. Check that before you build an email around a field. Pass audienceId to scope the stats to
the audience you’re sending to.
Need Help?
Our team is ready to support you at every step of your journey with Brew. Choose the option that works best for you:- Self-Service Tools
- Talk to Our Team
Search Documentation
Type in the “Ask any question” search bar at the top left to instantly find relevant documentation pages.
ChatGPT/Claude Integration
Click “Open in ChatGPT” at the top right of any page to explore it further with ChatGPT or Claude.