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

# Validate campaign personalization merge-tags before launch

> Run the pre-launch dry-run against the full merge-tag contract — the 5 standard contact fields, {{coupon_code}}, and campaign variables — and clear unresolved-tag and data-coverage warnings before you press Send.

# Validate campaign personalization merge-tags before launch

A campaign's message body renders `{{...}}` merge-tags per recipient at send time. A tag that does not resolve — a typo like `{{firstName}}`, or an unsupported field like `{{loyalty_tier}}` — renders **blank** for every recipient, silently, with no send-time error. Before launch, run the campaign's dry-run and check the `personalization` block and `warnings[]`: the dry-run validates every tag against the full contract and refuses the all-clear until each `{{...}}` token resolves against real recipient data.

This guide covers the personalization contract, where the dry-run surfaces violations, and the fixes for each warning shape. For the launch workflow around it, see [Send a campaign end-to-end](/guides/campaign-end-to-end). Journeys additionally validate the graph itself — see [Build, simulate, and launch a campaign journey](/guides/campaign-journey-builder).

## 1. Why the preview alone is not enough

The personalizer that renders a message at send time substitutes the contact's value for each `{{token}}` and drops any token it cannot resolve to an empty string — it never rejects the send. That means the risk lives entirely in pre-launch validation, which is exactly where the dry-run's personalization analysis operates: it extracts every `{{...}}` tag from the bodies the campaign would actually send (a journey's Send Message node bodies and subjects, or a blast/drip's `message_template` and `subject`) and classifies each as recognized or unresolved.

An unresolved tag makes the pre-launch report a false green — "audience, cost, and provider checks all pass" while the message ships a hole to every recipient. The dry-run closes that gap: any unresolved token adds a blocking warning, and `ready_to_launch` stays `false` until it is cleared.

## 2. The personalization contract

A merge-tag resolves when it matches exactly one of these layers:

1. **The 5 standard contact fields** — resolved per recipient from the contact record:

| Tag              | Contact field |
| ---------------- | ------------- |
| `{{first_name}}` | `first_name`  |
| `{{last_name}}`  | `last_name`   |
| `{{phone}}`      | `phone`       |
| `{{email}}`      | `email`       |
| `{{company}}`    | `company`     |

2. **`{{coupon_code}}`** — a per-recipient generated coupon code, driven by the campaign's coupon configuration. It resolves for every recipient once a coupon config exists.

3. **Campaign variables** — every key on `campaign.variables` (set at create or via the wizard) becomes a renderable tag. A `variables: { "promo_expiry": "Sunday" }` makes `{{promo_expiry}}` resolve identically for all recipients. The reserved coupon configuration object under `__coupon` is intentionally not a renderable tag — it drives `{{coupon_code}}`.

Anything else — wrong casing (`{{firstName}}` versus `{{first_name}}`), a custom profile attribute the platform does not carry (`{{loyalty_tier}}`), or a key computed outside the campaign — is **unresolved**, and it renders blank.

Two tags that are never flags:

* **Spaced tags** such as `{{ first_name }}` are normalized and validate as `{{first_name}}`.
* **Numeric positional tokens** like `{{1}}` or `{{2}}` belong to WhatsApp/RCS template parameters. A separate templating layer resolves them, so merge-tag validation skips them deliberately.

## 3. Where the report appears: the dry-run response

Run the campaign's dry-run before launch — it is read-only and safe to call on every edit:

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/campaigns/cmp_abc123/dry-run" \
  -H "X-API-Key: $ORBIT_API_KEY"
```

Two places carry the personalization result:

**The `personalization` block** lists what the validator found in the message bodies:

```json theme={null}
{
  "personalization": {
    "variables": ["{{first_name}}", "{{coupon_code}}"],
    "unresolved": ["{{loyalty_tier}}"],
    "coverage": [
      {
        "variable": "{{first_name}}",
        "field": "first_name",
        "present": 42,
        "sampled": 50
      }
    ]
  }
}
```

* `variables[]` — recognized tags the message uses, in `{{name}}` form.
* `unresolved[]` — tags outside the contract in section 2. Non-empty means at least one tag renders blank for every recipient.
* `coverage[]` — per-standard-field data coverage, present only for tags that resolve per-recipient from a contact field. `present`/`sampled` tells you how many sampled recipients carry a non-empty value; any gap means those recipients would receive a blank where the tag sits.

**The `warnings[]` array** turns both findings into actionable lines:

```json theme={null}
{
  "warnings": [
    "Personalization variable {{loyalty_tier}} is not a recognized contact field or campaign variable and will render blank for every recipient. Fix the tag or remove it before launch.",
    "Personalization {{first_name}} has no value for 8 of 50 sampled recipients — it will render blank in their message. Fill the missing contact data or remove the tag before launch."
  ],
  "ready_to_launch": false
}
```

`ready_to_launch` gates on an empty `warnings[]`, so a single unresolved or low-coverage tag holds back the green until it is fixed. The dashboard's pre-launch preview renders the same warnings; the API shape above is what your validation pipeline should gate on.

## 4. Field data coverage, including the CSV caveat

Tag-name validation passes even when the audience has no data for a recognized field — `{{first_name}}` is valid as a name but blank where the contact record is empty. The dry-run samples the audience's contact columns and reports coverage per standard-field tag (the `coverage[]` entries above). CSV audiences are the one exception: an uploaded CSV carries only recipient addresses, not per-recipient names, so the dry-run cannot confirm coverage from the file itself. At send time, a CSV recipient that matches a saved contact resolves standard fields from that contact; everyone else renders blank. When the message uses a standard-field tag against a CSV audience, expect this advisory:

```json theme={null}
{
  "warnings": [
    "Personalization {{first_name}} resolves only for CSV recipients already saved as contacts — the preview can't confirm your uploaded list carries this data, so it may render blank. Verify the recipient data or remove the tag before launch."
  ]
}
```

Treat it as blocking: verify that the CSV recipients exist as contacts, upload first/last names to contact records, or switch the audience to a list/segment before launch.

## 5. Worked example: catch a wrong tag before launch

A journey welcome message drafted as:

> "Hi {{firstName}}, your {{loyalty_tier}} rewards are waiting. Use code {{coupon_code}} for 10% off."

Run the dry-run on the draft campaign. `{{coupon_code}}` is recognized (assuming the coupon config is set), but `{{firstName}}` uses camelCase where the contract is snake\_case, and `{{loyalty_tier}}` is not a contact field or campaign variable. The response:

```json theme={null}
{
  "personalization": {
    "variables": ["{{coupon_code}}"],
    "unresolved": ["{{firstName}}", "{{loyalty_tier}}"]
  },
  "warnings": [
    "Personalization variables {{firstName}}, {{loyalty_tier}} are not a recognized contact field or campaign variable and will render blank for every recipient. Fix the tag or remove it before launch."
  ],
  "ready_to_launch": false
}
```

Fixes, in order:

1. Rename `{{firstName}}` to `{{first_name}}`.
2. Either remove `{{loyalty_tier}}`, or expose the value — set `variables: { "loyalty_tier_label": "Gold" }` on the campaign if the campaign itself defines the tier, or redesign the message to use a standard field. A campaign variables key makes the tag resolve to the same value for every recipient.
3. Re-run the dry-run. The warning clears and `ready_to_launch` flips to `true` only when both tags resolve.

The same shape applies to blast and drip bodies — the validator scans `message_template` and `subject` for every campaign type, and Send Message node bodies and subjects for journeys.

## 6. Gate launch on an empty warnings array

Build the gate into your pipeline, not just the dashboard:

* Call the dry-run (with `campaigns:read` scope) on every pre-launch check.
* Treat any `warnings[]` entry whose text begins with `Personalization` — or a non-empty `personalization.unresolved[]` — as a launch blocker.
* For a recurring campaign, re-run the dry-run after every template edit: a wrong-casing tag introduced in a body revision is a launch-blocking regression, not a silently-recurring blank.

For warehouse-driven personalization, move the data into contact custom fields or campaign variables upstream, then reference a tag the contract recognizes — see [computed traits](/guides/computed-traits) and [custom fields](/guides/custom-fields).
