# Templates and merge tags

Compose reusable email bodies and resolve per-recipient values with merge tags.

A template is a reusable body with `{{merge-tag}}` placeholders. Which tags are
available depends on the template's **category** — an auth template gets
different tags than an audience campaign.

Fetch the valid tags for a category rather than hardcoding them:

```bash
curl https://api.console.buildbase.app/api/emails/merge-tags/audience \
  -H "Authorization: $BUILDBASE_TOKEN"
```

```json
[
  { "title": "First Name", "mergeTag": "{{firstName}}", "sample": "Alice" },
  {
    "title": "Unsubscribe",
    "mergeTag": "{{unsubscribe}}",
    "sample": "https://example.com/unsubscribeLink"
  }
]
```

> **Note:**
  Templates are created and edited in the BuildBase console under **Email →
  Templates**. The API manages them but the editor is where the body is
  authored.


## Categories

8 categories exist, and
the category determines the merge-tag set:

| Category       | Used for                                    |
| -------------- | ------------------------------------------- |
| `auth`         | Sign-in, verification, password reset       |
| `user`         | Lifecycle mail addressed to one user        |
| `contact`      | Replies to contact-form submissions         |
| `audience`     | Campaigns to an audience list               |
| `beta-users`   | Beta programme mail                         |
| `notification` | Bodies rendered by `bb.notification.send()` |
| `default`      | System defaults shipped with BuildBase      |
| `custom`       | Anything outside the above                  |

A system template also carries an explicit tag list keyed by template ID, which
takes priority over the category. Requesting merge tags with a `templateId`
returns that template's exact event tags plus the shared base set.

## Two tags every audience email needs

| Tag                     | Resolves to                                  |
| ----------------------- | -------------------------------------------- |
| `{{unsubscribe}}`       | `<your-app>/unsubscribe-email-group?token=…` |
| `{{manage-preference}}` | `<your-app>/manage-email-preference?token=…` |

Both mint a per-recipient token, so the link identifies the recipient without a
login. They resolve at send time — a template previewed in the console shows the
sample value, not a live link.

These two are the exception to the substitution rules below: the main merge pass
deliberately leaves them untouched so a later pass can attach the recipient's
token. That is why they survive even though no value for them exists in the
merge data.

## Checking tag usage

`/api/emails/merge-tags/usage` reports which tags are actually referenced. The
route is registered **before** `/merge-tags/:category` so that `usage` is not
parsed as a category name — a detail worth knowing if you add categories.

## Behavior

| Behavior              | Detail                                                                                                    |
| --------------------- | --------------------------------------------------------------------------------------------------------- |
| **Unknown tag**       | Replaced with an **empty string**. It is not left visible, so a typo fails silently                       |
| **Resolution time**   | Send time, per recipient — not at template save                                                           |
| **Preview**           | Uses the `sample` value from the tag definition                                                           |
| **Category mismatch** | A tag valid in one category resolves to nothing in another. Fetch per category rather than reusing a list |
| **Nested data**       | Merge data is flattened, so `{{user.name}}` addresses a nested field                                      |
| **System templates**  | Can be reset to their shipped default, duplicated, or claimed for ownership                               |

> **Note:**
  **Falsy values render as empty.** Substitution uses `value || ''`, so a merge
  value of `0`, `false`, or an empty string produces nothing — indistinguishable
  from a misspelled tag. A body reading `You have {{credits}} credits left`
  renders as "You have credits left" at zero, which is exactly when you most
  want to send it.

Pre-format such values into strings in the sending data ("0") rather than
passing raw numbers.



Because a typo and a legitimately empty value look identical in the output,
send yourself a test before a campaign goes to an audience.

One convenience worth knowing: markdown renderers percent-encode link
destinations, so a tag used as a URL — `[Sign in]({{link}})` — would otherwise
arrive as `%7B%7Blink%7D%7D`. That is normalized back before substitution, so
merge tags work inside link targets.

## Next Steps

- [Sending domains](/email/sending-domains) — verify a domain before sending.
- [Campaigns overview](/email/overview) — the send pipeline.
