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

# Template-variable catalogue — what the picker consumes

> Use the campaign template-variable catalogue to build a picker with the standard, contact, and tenant-scoped custom merge fields.

# Template-variable catalogue — what the picker consumes

When you build a campaign template composer, use the catalogue endpoint as the source for the merge-tag picker. It returns the fields available to the caller's tenant with the label, group, and type the picker needs. Insert the row's `key` between double braces, not its display name.

## What the endpoint is

Call `GET /api/v1/campaigns/template-variables` with an authenticated API key. The response is a standard Orbit envelope whose `data` array contains rows shaped like this:

```json theme={null}
{
  "key": "first_name",
  "displayName": "First name",
  "group": "standard",
  "type": "text"
}
```

The catalogue has three groups:

* **`standard`** — the five resolver-backed fields: `first_name`, `last_name`, `phone`, `email`, and `company`.
* **`contact`** — non-standard fields from the contact record that the caller can use when composing a message.
* **`custom`** — custom-field definitions owned by the caller's tenant.

Use `key` as the immutable insertion value. For example, a picker row with `key: "first_name"` inserts `{{first_name}}`; it must not insert `{{First name}}`.

## Why a catalogue exists

A picker should not offer a tag that the delivery resolver will remove. The standard group is derived from the resolver's `TEMPLATE_VARIABLES` in `template-interpolate.ts`, so the catalogue and the delivery path share one source of truth. If a resolver field is added without a matching friendly label, the catalogue uses a humanized fallback rather than silently dropping the row.

This makes drift visible: the catalogue tests compare its standard rows with the resolver's variables. A change to the resolver therefore fails loudly during validation instead of leaving a picker with a missing or undeliverable merge tag.

## Group semantics

### Standard fields

Standard fields are the safe, resolver-backed subset. The API uses `STANDARD_DISPLAY_NAMES` for labels such as `First name`, `Last name`, and `Company`. The `humanizeFieldName` fallback turns an unlisted key such as `customer_tier` into `Customer tier` while keeping the key unchanged.

The display label is for the UI only. Always insert `{{key}}` in the template body.

### Contact fields

The `contact` group contains non-standard contact fields available to the authenticated caller. Keep these separate from standard fields in the picker so authors can see which values come from the broader contact record rather than the resolver's guaranteed standard set.

### Custom fields

The `custom` group is tenant-scoped. The endpoint derives it from the custom-field definitions belonging to the caller's tenant, so one tenant's definitions are never exposed to another tenant. The read is performed through the tenant-safe query path with transient database-read retry (`listCustomFieldDefinitions` via `safeTenantQuery` and `retryDbReadOnTransient`). Do not cache these rows across tenants; refresh them when the active tenant changes.

## Sample request and response

Send the request with the same API key and tenant context used by the campaign composer:

```bash theme={null}
curl "$ORBIT_API_BASE/api/v1/campaigns/template-variables" \
  -H "X-API-Key: $ORBIT_API_KEY"
```

The response envelope contains rows from all three groups. A tenant with one contact field and one custom field might receive:

```json theme={null}
{
  "data": [
    {
      "key": "first_name",
      "displayName": "First name",
      "group": "standard",
      "type": "text"
    },
    {
      "key": "email",
      "displayName": "Email",
      "group": "standard",
      "type": "text"
    },
    {
      "key": "lifecycle_stage",
      "displayName": "Lifecycle stage",
      "group": "contact",
      "type": "text"
    },
    {
      "key": "customer_tier",
      "displayName": "Customer tier",
      "group": "custom",
      "type": "text"
    }
  ],
  "meta": {
    "request_id": "req_01J8Z9K3P4Q5R6S7T8U9V0W1X2",
    "timestamp": "2026-08-19T12:00:00.000Z"
  }
}
```

Treat `data` as a flat list. Group and sort it in the UI, and preserve the API's keys when inserting tags. The `meta` object is an envelope, not another catalogue group.

## Best practice: prefer standard fields

Treat `standard` as the safe subset for reusable campaign content. Prefer `{{first_name}}`, `{{last_name}}`, `{{phone}}`, `{{email}}`, and `{{company}}` unless the message specifically needs a contact or custom field. Standard fields are the ones tied directly to the delivery resolver; contact and custom values depend on the available record and tenant definitions.

For optional personalization, keep a fallback in the surrounding copy and test with contacts that have the field populated and contacts that do not. Never hard-code a display label or assume that a custom field exists in every tenant.

## Implementation references

The standard labels and humanized fallback are defined in [`template-variables.ts`](https://github.com/devotel/orbit/blob/main/apps/api/src/routes/campaigns/template-variables.ts). The public route and response envelope are defined in [`template-variables.controller.ts`](https://github.com/devotel/orbit/blob/main/apps/api/src/routes/campaigns/template-variables.controller.ts).

## Related guides

* [Outbound templates](/guides/outbound-templates) — create, approve, and reuse campaign templates.
* [Custom fields](/guides/custom-fields) — manage tenant-owned custom contact fields.


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