Skip to main content
Merge tags pull contact and trigger data into an email at send time. Write a field name in the copy, and each recipient sees their own value. This page is the reference for the syntax and for which fields resolve.
One feature, three names. These docs and the contacts API say merge tags. The automation surface says variables, 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 like {{ firstName }}, with or without a fallback.
Variable names are case-sensitive. {{ firstName }} matches a field named firstName, not firstname. A trigger field resolves by the exact name it’s declared with. A custom property resolves by the camelCase name Brew stores it under. A property sent as Plan is {{ plan }}.Triple braces ({{{firstName}}}) are a legacy spelling, not merge tags. In a one-off Email they ship as literal text.In an automation, {{{ name }}} still reads a trigger field of that exact name. Publish refuses any other triple-brace token, such as {{{ trigger.code }}}, which never renders. Write {{ trigger.code }} instead.

Logic, Loops, and Filters

Brew renders Liquid, so conditionals, loops, and filters work in any email:
See Liquid Templating below for the full grammar. If two variants differ by more than a sentence, branch the flow with a Split node instead. Each path gets its own email and its own analytics.

Where They Work

  • Email body
  • Subject line
  • Preview text
  • fromName and replyTo, on automation sends
  • Filter and Split node conditions, which reference trigger payload fields by name
In the TypeScript SDK, subject and previewText on a sendEmail node take the same syntax. They are evaluated against the trigger payload at fire time. A missing value and a missing field fail differently. Missing value. The field exists but is empty for this recipient, such as a contact with no first name. In the body, subject, or preview text the tag renders empty, or its fallback, and the send goes out. fromName and replyTo resolve once per fire, so they take trigger fields only. A contact property there is refused at publish. Missing field. In an automation, a tag or a Filter or Split condition that names a field that doesn’t exist blocks publishing. That covers a field the trigger doesn’t declare and a name no contact property matches. A fallback doesn’t get it past publish. It stands in for a missing value, not for a field that can never arrive.

What You Can Reference

Contact Properties

Every contact carries three default properties that resolve as merge tags. subscribed, createdAt, and updatedAt are filter and sort fields, not merge tags, so they stay out of the variable picker. To put 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 named plan is {{ plan }}. Use the stored key, not the spelling the data arrived with. Brew folds every custom-property name to one camelCase key. A push source column becomes {{ pushSource }}, and team_size or Team Size becomes {{ teamSize }}. A tag written with the original spelling doesn’t match. list_contact_fields over MCP and GET /v1/fields list the exact keys.

Trigger Payload Fields

In an automation, every field declared on the trigger event resolves by name. An event carrying orderId gives you {{ orderId }}. A payload field can be an object with its own fields. Declare the nesting on the trigger. Each leaf then resolves by its dotted path ({{ user.plan }}) and shows in the variable picker. Undeclared keys, at any depth, are reported as warnings on the fire and never reach the template. An automation can’t publish with a tag that names one.
Connected sources declare their own payload fields, and each integration page lists what its events carry. See Stripe, Clerk, Shopify, Stytch, Supabase, WorkOS, and RevenueCat.

When a Tag Does Not Resolve

In an automation, Brew checks every tag against the trigger’s declared fields and your contact properties before publish. It refuses a tag that names neither. Outside an automation, an unresolved tag renders empty and the email still sends. A typo is silent there, so check tags before a large send. Three things make a tag unresolvable:
Names are case-sensitive. A trigger field must match its declared name exactly, so {{ firstname }} does not match firstName. A custom property must match its stored camelCase key, so {{ Plan }} does not match plan.
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.
Brew strips some provider fields before an automation sees them, so they never resolve. Stripe’s opaque object IDs work this way. The customer ID still reaches the contact as the stripeCustomerId custom field, so filter on it there.
Use a fallback wherever an empty value would read badly. Hi {{ firstName }}, becomes Hi , for a contact with no name. Hi {{ firstName | there }}, reads correctly either way. The sending domain changes nothing here, including a transactional-purpose one. An unresolved tag renders empty, and a template syntax error fails loudly. Test both before wiring production traffic.

Liquid Templating

Brew renders standard Liquid, the template language Customer.io also uses, for every workspace. There is nothing to enable: merge tags and Liquid are one language, so the forms above and real logic share a template. See Migrate from Customer.io if you are bringing templates across.
Conditionals, loops, filters, {% assign %}, {% case %}, and the rest of standard Liquid render at send time, per recipient. The file-inclusion tags {% include %} and {% render %} are disabled. They fail the template loudly rather than being ignored.
Encode values you put inside a link. A server reads the + in Ab3+x/9 as a space, and an & splits the query string. Add | url_encode to each value, as in ?token={{ trigger.reset.token | url_encode }}, or pass the whole link already encoded as one payload value.

Namespaces

Nested Payloads

Trigger fires accept nested JSON as payload, arrays and objects included, and templates address it by path:
Limits: 64 KiB serialized, 10 levels of nesting, 250 items per array, 100 top-level keys. Scalar top-level keys also resolve plain merge tags, so "firstName": "Ada" still fills {{ firstName }}. A test: true send renders exactly like the live fire. Don’t guess the payload shape. The trigger’s payload contract, its payloadSchema, is the exact shape every fire is validated against, nesting included. It is on the trigger’s page in the app, and everywhere else you can read a trigger: To turn the contract into TypeScript types with a CI drift gate, 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 fallback. {{ price | currency }} is a Liquid filter. Brew recognizes registered filter names and treats other words as fallback text. Filters can surround one continuous run of fallback words: Filter names are case-sensitive: | Upcase is fallback text. If a fallback word is also a filter name, such as first, write it with default:: {{ plan | default: "first" }}.

Values Print as Text

Brew escapes values printed into an email’s body. A name containing <a href="https://example.com">Verify</a> prints those characters instead of a working link. This protects fields that another person supplied, such as an inviter’s name or a comment.
  • Trusted HTML: add | raw as the last filter, and only for HTML you produced yourself, as in {{ trigger.order_html | raw }}. Never use it on names, messages, or anything a user typed.
  • Building markup in Liquid: markup inside {% capture %} prints as markup, with values inside it escaped. Tags in string literals or | append text are escaped.
  • Subject and preview text: these render as plain text.
  • Links: escaping blocks added markup but doesn’t check the URL. Check a complete link value such as href="{{ trigger.invite_url }}" before you fire the event.
An automation’s fromName, replyTo, and fromAddress can use trigger fields. Each rendered header must hold one value:
  • A sender name stays on one line, without <, >, or text-direction controls
  • A reply-to or from address must be a single address

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: Build an Automation walks through declaring a payload, and Add Contacts covers getting properties onto contacts. The trigger fire is the analogue for per-send data. POST /v1/automations/triggers/{triggerEventId}/fire carries a payload like Customer.io’s message_data, nested JSON included. A transactional email in Brew is exactly this. It is an automation sending from a transactional-purpose domain, fired per recipient with the moment’s data. See Nested Payloads above and Transactional Email.

Check Before You Send

Send yourself a test to check both cases. A test doesn’t read a contact’s saved data. Fill a tag’s value under Variables to see it resolve, and leave it blank to see the fallback. QA an Email Before a Big Send covers the wider pre-send routine. For automations there is a machine check too. Publish with dryRun: true, and blockingIssues[] in the response names every unresolvable reference with its node, surface, and variable. Every entry blocks publish. Run it after swapping a trigger or removing a payload field, which is when references break. Each entry’s fatal flag says what the reference would have done at send time: A reference can also stop resolving after publish, for example when an edited email uses an undeclared tag. The automation then stops starting runs rather than sending blank tags. Each fire still answers 202, and its notStarted[] names the automation and the tag. Declare the field on the trigger and the next fire starts runs, or remove the tag and republish. Brew refuses a trigger edit that would break a published automation this way, with 409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS. That covers removing a field the automation uses. It also covers declaring the shape of an open object or list so a tag stops resolving. Unpublish the automation first, or keep that field. Personalizing on a field most contacts have not filled in means most of them see the fallback. GET /v1/fields?include=coverage reports the share of contacts with a non-empty value for each field. Pass audienceId to scope it 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:

Search Documentation

Type in the “Ask any question” search bar at the top left to instantly find relevant documentation pages.

Ask ChatGPT or Claude

Click here to open the Copy page menu at the top of this page, then send the page to ChatGPT, Claude, or your editor.