Skip to main content

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. Journeys additionally validate the graph itself — see Build, simulate, and launch a campaign journey.

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:
  1. {{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.
  2. 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:
Two places carry the personalization result: The personalization block lists what the validator found in the message bodies:
  • 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:
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:
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 , your rewards are waiting. Use 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:
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 and custom fields.