Merge Tags
Merge tags are placeholders you embed in a message body that the platform substitutes with per-recipient or per-account values at send time. The same syntax works on every send endpoint.
# Body sent
"Hi {{contact_first_name}}, your {{contact_custom_field_1}} tier rewards are ready!"
# Body delivered to a recipient whose contact has firstName=Alice + custom field 1=Gold
"Hi Alice, your Gold tier rewards are ready!"How resolution works
For every recipient, the platform:
- Scans the
bodyfor{{...}}placeholders. - Resolves each placeholder against the recipient's contact profile (looked up by phone number against your account's contact book) or your account's configuration.
- Substitutes the resolved values into the body.
- Sends the resulting per-recipient message via the carrier.
One API call → personalized messages for every recipient. The platform does the contact lookup automatically — you don't need to fetch profile data yourself and string-template the body.
Missing data resolves to an empty string
No error is raised when a referenced value is missing. Two cases trigger an empty-string substitution:
- No contact for the recipient's phone number — every merge tag in the body resolves to an empty string for that recipient.
- Contact exists but the specific field is empty — only that one variable resolves to an empty string; other variables on the same recipient still resolve normally.
In both cases the message is sent (with the variables elided), not rejected. Make sure each recipient is a saved contact with the fields you reference populated before triggering the send — use the Create and enroll a contact or Upsert a contact from a CRM recipes to seed your contact book first.
Worked example — same body sent to three recipients:
Body: "Hi {{contact_first_name}}, your {{contact_custom_field_1}} tier reward is ready."| Recipient | Contact state | Delivered text |
|---|---|---|
Alice (firstName=Alice, cf1=Gold) | Match, both fields populated | Hi Alice, your Gold tier reward is ready. |
Bob (firstName=Bob, cf1=empty) | Match, but custom field 1 not set | Hi Bob, your tier reward is ready. |
+18005559999 (no contact in your book) | No match | Hi , your tier reward is ready. |
Plan your wording so the message reads naturally when a variable is blank. When personalization isn't critical, prefer non-templated copy ("Welcome back!") over a templated greeting that might render "Hi , welcome back!".
Where merge tags work
The same resolver and variables apply to all three send endpoints:
| Endpoint | What gets templated |
|---|---|
POST /api/v1/messaging/message | The single body field — one recipient. |
POST /api/v1/messaging/broadcast | The single body field — same template, many recipients. |
POST /api/v1/messaging/batch | Each recipients[].body field — different templates per recipient in one call. |
Same variables, same empty-string behavior on missing data, same resolver on all three.
Available variables
Contact-scoped variables
Resolved from the recipient's contact record (looked up by phone number):
| Variable | Resolves to |
|---|---|
{{contact_first_name}} | Recipient's firstName |
{{contact_last_name}} | Recipient's lastName |
{{contact_email}} | Recipient's email |
{{contact_phone_number}} | Recipient's phoneNumber in international format with spaces, e.g. +1 213 373 4253 for a US number, +44 20 7183 8750 for UK. Counts as more characters than E.164 (+12133734253) — see Encoding & message length below if your message is near the 160-char SMS segment limit. |
{{contact_gender}} | Recipient's gender |
{{contact_date_of_birth}} | Recipient's birthDate |
{{contact_zip_code}} | Recipient's address.postalCode |
{{contact_custom_field_1}} … {{contact_custom_field_7}} | Recipient's custom-field values — see Custom field variables |
Date helpers
| Variable / helper | Resolves to |
|---|---|
{{current_date}} | Today's date, MM/DD/YYYY |
{{dateOffset "7d"}} | Today + N days/weeks/months/years, MM/DD/YYYY (suffixes: d, w, m, y) |
Offer expires {{dateOffset "7d"}}. → Offer expires 05/24/2026.Tracking links
| Helper | Resolves to |
|---|---|
{{trackingLink "domain/path"}} | The branded short URL for the registered tracking link domain/path, with per-recipient annotation appended for click-through analytics. |
The argument must be the bare
domain/pathpair — nothing else.Specifically, the string inside
"..."must be the exact short-link identifier registered under your account's tracking-link settings (e.g.fct.la/promo,link.example.com/spring). Do NOT include:
- A URL scheme —
https://fct.la/promois rejected.- A leading slash —
/fct.la/promois rejected.- A trailing slash —
fct.la/promo/is rejected.- Query parameters or anchors —
fct.la/promo?ref=emailorfct.la/promo#anchoris rejected.- Surrounding whitespace inside the quotes —
" fct.la/promo "is rejected.When the platform can't find a tracking link matching the exact argument, the placeholder is replaced with the raw text of your argument (e.g.
https://fct.la/promo) and no per-recipient annotation is attached — clicks on the link can't be attributed back to the contact who tapped it, which silently breaks your click-through analytics. The message still sends.
Body sent
"Tap to redeem: {{trackingLink "fct.la/promo"}}"
Body delivered (recipient Alice)
"Tap to redeem: fct.la/abc/xy7z"Tracking links resolve before merge-tag variables, so you can pair them in a single body without ordering issues:
"Hi {{contact_first_name}}, your {{contact_custom_field_1}} reward awaits: {{trackingLink "fct.la/rewards"}}"For multi-recipient sends the annotation is computed per-recipient, so each contact's click ties back to their own contact record — no extra integration on your side.
Custom field variables
Custom-field variables follow the pattern {{contact_custom_field_N}} where N is the 1-based position of the field in your account's schema (1 = your first custom field, up to 7).
If you set up your custom fields ahead of time and know their order, hard-code the merge tags directly in your message bodies — no extra API call needed. For dynamic integrations that want to discover available fields at runtime (e.g. a "Send a Campaign" UI in your own dashboard), fetch the schema once and read the key field of each entry:
curl -X GET https://platform.textingline.com/api/v1/contacts/custom-fields/schema \
-u "your_key_id:your_key_secret"// Node 18+ (global fetch)
const KEY_ID = process.env.TEXTINGLINE_KEY_ID;
const KEY_SECRET = process.env.TEXTINGLINE_KEY_SECRET;
const auth = 'Basic ' + Buffer.from(`${KEY_ID}:${KEY_SECRET}`).toString('base64');
const res = await fetch(
'https://platform.textingline.com/api/v1/contacts/custom-fields/schema',
{ headers: { Authorization: auth } },
);
const schema = await res.json();
// schema.fields is an array of { label, key, type, options? }
// Build a label → merge-tag-name map for your authoring UI:
const tagByLabel = Object.fromEntries(
schema.fields.map((f) => [f.label, `{{${f.key}}}`]),
);
console.log(tagByLabel);
// → { "Favorite Color": "{{contact_custom_field_1}}", "Birthday": "{{contact_custom_field_2}}" }import os
import requests
from requests.auth import HTTPBasicAuth
AUTH = HTTPBasicAuth(
os.environ['TEXTINGLINE_KEY_ID'],
os.environ['TEXTINGLINE_KEY_SECRET'],
)
resp = requests.get(
'https://platform.textingline.com/api/v1/contacts/custom-fields/schema',
auth=AUTH,
)
resp.raise_for_status()
schema = resp.json()
# schema['fields'] is a list of { label, key, type, options? }
tag_by_label = {f['label']: f"{{{{{f['key']}}}}}" for f in schema['fields']}
print(tag_by_label)
# → {'Favorite Color': '{{contact_custom_field_1}}', 'Birthday': '{{contact_custom_field_2}}'}{
"fields": [
{ "label": "Favorite Color", "key": "contact_custom_field_1", "type": "SINGLE_SELECT", "options": ["Red", "Green", "Blue"] },
{ "label": "Birthday", "key": "contact_custom_field_2", "type": "DATE" },
{ "label": "Points", "key": "contact_custom_field_3", "type": "NUMBER" }
]
}Each entry has a label (the operator-chosen display name, used as the JSON key when reading/writing customFields on a contact), a key (the merge-tag variable name — embed inside {{...}} in message bodies), and a type (one of TEXT, NUMBER, BOOLEAN, DATE, SINGLE_SELECT, MULTI_SELECT). SINGLE_SELECT / MULTI_SELECT entries also include options listing the allowed values.
The key field is stable across label renames. If you rename "Favorite Color" → "Color" in the dashboard, your existing templates referencing {{contact_custom_field_1}} keep working. (Your integration's customFields JSON paths — e.g. "customFields": { "Favorite Color": ... } — would need updating, but the merge-tag side is unaffected.) See Custom Fields.
Unknown placeholders pass through
The resolver only touches known variables. If a body contains a {{...}} placeholder that isn't a recognized merge tag (e.g. {{order_id}}), the placeholder is left as-is in the delivered text — it isn't replaced and it isn't an error. This protects unrelated {{...}} substrings embedded in URLs or JSON payloads from being mangled.
If you're authoring templates programmatically and want to catch typos, validate against the variable list on this page before sending.
Encoding & message length
Substituted values count toward the SMS segment budget. Two things to watch:
- GSM-7 vs UCS-2 encoding. Standard SMS uses 7-bit GSM-7 encoding (160 chars per segment). The moment a body contains any character outside the GSM-7 alphabet — emoji, curly quotes (
"/"), em dashes (—), accented letters (é,ñ), most non-Latin scripts — the entire SMS switches to UCS-2 encoding (70 chars per segment). AfirstNameofRenéewill quietly halve your segment capacity for that recipient. If you need to support arbitrary names, plan for UCS-2 budgets. - Phone-number formatting.
{{contact_phone_number}}resolves to the international form with spaces (+1 213 373 4253— 15 chars), not E.164 (+12133734253— 12 chars). Adds 3 characters per US recipient versus E.164.
There's no compile-time check for either — both happen at carrier-encoding time, after substitution. Test bodies that approach the 160-char limit with real recipient data (including a name with accented characters and an emoji) to confirm the segment count.
Common pitfalls
A short list of the issues that come up most often, in roughly the order we see them in support:
- Typos resolve to empty strings, not errors.
{{contact_first_naem}}is an "unknown placeholder" and survives literally;{{first_name}}(missing thecontact_prefix) is also unknown. The platform doesn't validate template authoring at submit time. Cross-check every variable name against the Available variables table above before going to production. For dynamic templates, also call the schema endpoint and validate against the returnedkeyvalues. - Recipient must be a saved contact for personalization to work. A phone number that isn't in your contact book gets every merge tag in the body resolved to an empty string. Seed your contact book first — see Upsert a contact from a CRM.
- Test with one recipient before blasting thousands. Send the same templated body to a single test contact (with realistic field values), inspect the delivered SMS on a real phone, then run the full broadcast/batch. Catches encoding surprises, accidental empty fields, missing tracking-link registrations, and unnatural wording with blanks.
mediaUrlis not templated. Merge tags are only substituted inbody— putting{{contact_first_name}}inmediaUrlwill send a literal request to that URL with the braces intact (and likely 404). For per-recipient media, use the batch endpoint and compute themediaUrlyourself for each recipient.- Tracking-link arg must be the bare
domain/path. No scheme, no query params, no trailing slash — see the Tracking links callout. Mistakes here silently break click-through analytics without breaking the send. - Merge-tag substitutions count toward segment length. See Encoding & message length above.
Related
- Send a personalized broadcast with merge tags — full broadcast workflow (curl, JavaScript, Python).
- Custom Fields — manage the schema that powers
{{contact_custom_field_N}}. - Upsert a contact from a CRM — seed your contact book before sending.