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

# Merge Tags

> Reference for Brew merge tags and Liquid templating: syntax, fallbacks, nested payloads, which fields resolve, and what renders when one doesn't.

Merge tags pull contact and trigger data into an email at send time. They are Brew's dynamic variables: 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.

<Note>
  **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 }}`.
</Note>

## Syntax

A merge tag is a field name inside double braces.

| Form | Example | Renders |
| - | - | - |
| Plain | `{{ firstName }}` | `Alex` |
| Fallback | `{{ firstName \| there }}` | `there`, when the field is empty |

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.

<Warning>
  Variable names are case-sensitive. `{{ firstName }}` matches a field named `firstName`, not `firstname`. 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, and publish refuses any other triple-brace token, such as `{{{ trigger.code }}}`, which never renders. Write `{{ trigger.code }}` instead.
</Warning>

### 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:

```liquid theme={null}
{% if plan == "pro" %}...{% endif %}
{% for item in trigger.items %}...{% endfor %}
{{ createdAt | date: "%B %d" }}
```

See [Liquid Templating](#liquid-templating) below for the full grammar.

Logic in the template isn't always the right tool. If two variants differ by more than a sentence, branching the flow reads better in analytics than one email with hidden variants. A [Split node](/create-emails/automations) sends a different email down each path.

## 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](/sdks/typescript/resources), the `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. When a field exists but has no value for this recipient, such as a contact with no first name or an optional trigger field the fire left out, a tag in the body, subject, or preview text 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.

In an automation, a tag or a Filter or Split condition that names a field that doesn't exist blocks the automation from publishing. That covers a field the trigger doesn't declare and a name no contact property matches. Every recipient would get that tag blank, and a condition that can't evaluate can't route contacts. 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.

| Merge tag | Example |
| - | - |
| `{{ email }}` | `philip@brew.new` |
| `{{ firstName }}` | `Philip` |
| `{{ lastName }}` | `Sørensen` |

`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](/audience/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 }}`. 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 carrying `orderId` 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, so an automation can't publish with a tag that names one.

<Note>
  Connected sources declare their own payload fields, and each integration page lists what its events carry. See [Stripe](/integrations/import/stripe), [Clerk](/integrations/import/clerk), [Shopify](/integrations/import/shopify), [Stytch](/integrations/import/stytch), [Supabase](/integrations/import/supabase), [WorkOS](/integrations/import/workos), and [RevenueCat](/integrations/import/revenuecat).
</Note>

## When a Tag Does Not Resolve

In an automation, Brew checks every tag against the trigger's declared fields and your contact properties before it publishes, and refuses a tag that names neither. Outside an automation, an unresolved tag renders as empty rather than as an error, and the email still sends. That makes a typo silent there, so it's worth checking a tag before a large send.

Three things make a tag unresolvable:

<AccordionGroup>
  <Accordion title="The name does not match" icon="font">
    Names are case-sensitive and must match the declared field exactly. `{{ firstname }}` does not match a field named `firstName`.
  </Accordion>

  <Accordion title="The field is not declared on the event" icon="list-check">
    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`.
  </Accordion>

  <Accordion title="The field is internal" icon="eye-slash">
    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.
  </Accordion>
</AccordionGroup>

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 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](/migrations/customerio) if you are bringing templates across.

```liquid theme={null}
{% if customer.plan == "pro" %}Thanks for going Pro.{% endif %}
{% for p in trigger.projects %}<li>{{ p.name }}</li>{% endfor %}
{{ trigger.total | currency }} due {{ trigger.dueAt | date: "%B %d" }}
```

Conditionals, loops, filters, `{% 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

| Prefix | Resolves from | Example |
| - | - | - |
| `customer.` or `contact.` | The recipient's contact, standard and custom properties | `{{ customer.first_name }}` |
| `trigger.` or `payload.` | The data sent with this fire: the `payload` on the trigger fire (or on a test send) | `{{ trigger.order.total }}` |
| none | Bare names keep working exactly as before | `{{ firstName }}` |

### Nested Payloads

Trigger fires accept nested JSON as `payload`, arrays and objects included, and templates address it by path:

```json theme={null}
{ "order": { "total": 4900, "items": [{ "name": "Pro seat", "qty": 2 }] } }
```

```liquid theme={null}
{% for item in trigger.order.items %}{{ item.qty }} × {{ item.name }}{% endfor %}
```

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, 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. The readiness check, `GET /v1/automations/triggers/{triggerEventId}/readiness`, 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](/sdks/typescript/resources) as `brew.automations.triggers.get(triggerEventId)`, and over MCP as `list_triggers` (pass the `triggerEventId`) and `check_trigger`. To turn the contract into TypeScript types in your codebase, with a CI drift gate and typed SDK calls, see [Typed payload contracts](/api-reference/guides/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:

| You want to personalize on | Put the data here | Then reference |
| - | - | - |
| Something true about the person | A contact property, standard or custom | `{{ plan }}` |
| Something true about the moment | A field declared on the trigger event | `{{ orderId }}` |

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](/create-emails/build-an-automation) walks through declaring a payload, and [Add Contacts](/audience/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](#nested-payloads) above and [Emails vs Automations](/create-emails/emails-vs-automations#a-note-on-transactional-emails).

## 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](/recipes/qa-before-a-big-send) covers the wider pre-send routine.

For automations there is a machine check too. Publish with `dryRun: true` and the response's `blockingIssues[]` names every unresolvable reference, with its node, surface, and variable.

Every entry blocks publish. Its `fatal` flag says what the reference would have done at send time: `true` when the run or the send would break (a Filter or Split condition, invalid tag syntax, a triple-brace field with no fallback), and `false` when a tag would render blank (in the subject, preview text, body, `fromName`, or `replyTo`). Run it after swapping a trigger or removing a payload field, which is exactly when references break.

If a reference stops resolving after the automation is published, for example because its email was edited to use a tag the trigger doesn't declare, the automation 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 from the email and republish. Brew refuses a trigger edit that would break a published automation this way, such as removing a field it uses or declaring the shape of an open object or list so one of its tags no longer resolves (`409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS`). Unpublish the automation first, or keep that field.

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:

<Tabs>
  <Tab title="Self-Service Tools">
    <CardGroup cols="2">
      <Card title="Search Documentation" icon="magnifying-glass" color="#c44925">
        Type in the "Ask any question" search bar at the top left to instantly find relevant documentation pages.
      </Card>

      <Card title="ChatGPT/Claude Integration" icon="robot" color="#c44925">
        Click "Open in ChatGPT" at the top right of any page to explore it further with ChatGPT or Claude.
      </Card>
    </CardGroup>
  </Tab>

  <Tab title="Talk to Our Team">
    <CardGroup cols="2">
      <Card title="Schedule a Call" icon="calendar" color="#c44925" href="https://calendar.google.com/calendar/u/0/appointments/schedules/AcZssZ1iYoRUG1J792XQpbuQLjSRRDupr7MwraFK-HQRCtTYdBmrQi8nZu2qXfzKQigb8gbKJK3KN3-R">
        Book time with our founders for personalized guidance on strategy, best practices, or complex implementation questions.
      </Card>

      <Card title="Call Us Directly" icon="phone" color="#c44925">
        Need immediate assistance? Reach us at **+1-(332)-203-2145** for urgent issues or time-sensitive questions.
      </Card>

      <Card title="Slack Channel" icon="slack" color="#c44925">
        Our preferred support channel. You'll receive an invite after signup for direct founder support and fast responses.
      </Card>

      <Card title="Email Support" icon="envelope" color="#c44925" href="mailto:support@brew.new">
        Contact us at **[support@brew.new](mailto:support@brew.new)** for detailed inquiries or if you prefer not to use Slack.
      </Card>
    </CardGroup>
  </Tab>
</Tabs>


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