Embed placeholders like {{customer_name}} in question text. Each recipient sees their own resolved value while a single Survey + question_id keeps analytics aggregating cleanly across the whole audience.
Tags are first-class objects on a survey. Question text containing {{undeclared_tag}} is a hard error at save time. You declare a tag once, then reference it freely. At runtime, the resolved values are frozen onto a server-side ResponseSession on the recipient’s first survey load and cannot be changed by tampering with URL parameters.
Three tag sources: SYSTEM, CUSTOM, FROM_LIBRARY. Library entries are cloned, not referenced live.
Every interpolated value is HTML-escaped server-side; XSS via merge values is not possible.
Anonymous URL params are rejected. Only invitation tokens or HMAC-signed share links carry merge data.
| Constraint | Value |
|---|---|
| Key format | ^[a-z][a-z0-9_]{0,39}$ . Lowercase snake_case, 1–40 chars, must start with a letter |
| Value cap | 200 characters. Longer values are truncated server-side with an ellipsis. |
| Placeholder syntax | {{ key }} , surrounding whitespace allowed. Uppercase or invalid keys are not matched. |
| System tags (auto-seeded) | email, first_name, last_name . Cannot be renamed or deleted |
| Recursive interpolation | Disabled. A value containing {{other}} renders as literal text. |
| Session TTL | 7 days, sliding on activity. Same (surveyId, inviteToken) resumes the same session. |
| Signed share-link TTL | 30 days default; 60s minimum, 1y maximum |
All endpoints under /api/v1/... require an organization API key (rlk_live_* / rlk_test_*) with the scopes listed below. See Authentication for details.
/api/v1/surveys/{id}/merge-tagsList declared tags or create a new CUSTOM tag. POST body: { key, label, required, fallback?, saveToLibrary? }.
/api/v1/surveys/{id}/merge-tags/{tagId}Read, update, or delete a tag. PATCH supports rename-with-cascade. Pass renameKey=true and a new key to rewrite every reference in the survey atomically. DELETE returns 409 STATE_CONFLICT with location details when the tag is still referenced.
/api/v1/surveys/{id}/merge-tags/importClone an OrgMergeTag into the survey with source=FROM_LIBRARY. Body: { libraryTagId, required?, fallback? }.
/api/v1/organization/merge-tagsList or create org-wide library tags. Library entries are templates. They are cloned into surveys via the import endpoint, not referenced live.
/api/v1/surveys/{id}/share-linksGenerate an HMAC-signed share URL carrying merge data. Returns { url, expiresAt, ctx, sig, v }. Requires SHARE_LINK_SECRET env var; rejects mergeData keys that are not declared on the survey.
/api/v1/surveys/{id}/sessionsMaterialize a ResponseSession for a respondent. Body: { inviteToken? | signedCtx + signedSig + signedV? }. Idempotent on (surveyId, inviteToken).
/api/v1/surveys/{id}/invitationsExisting invitations endpoint now accepts an optional mergeData field. The same values apply to every invite in the batch and are persisted to SurveyInvite.mergeData.
When a recipient opens an invite or signed share link, the page calls ensureResponseSession server-side. The function resolves merge values once, persists them to a ResponseSession row, and passes them to the client alongside the declared tags.
1. SurveyInvite.mergeData (CSV row, batch invitation API)
2. Contact.properties (when invite has an associated contact)
3. Respondent.attributes (HRIS-synced data)
4. Signed-link payload (when arriving via signed share URL)
5. Tag fallback
6. "" (empty string)The resolved context is HTML-escaped before injection into rendered question titles, descriptions, and email HTML. Anonymous URL parameters (e.g. ?customer_name=Foo) are not honored, only invitation tokens and signed share links can carry merge data.
When a recipient submits, the resolved context is snapshotted onto Response.sourceDetails.mergeContext alongside a contextSource tag (INVITE / CONTACT / SIGNED_LINK / ANONYMOUS). The originating ResponseSession is marked completedAt and linked to the new response.
{
"browser": "Chrome",
"device": "desktop",
// ...existing fields
"mergeContext": {
"customer_name": "Acme",
"region": "EU"
},
"contextSource": "INVITE"
}POST /api/v1/surveys/{id}/launch evaluates merge-tag readiness before transitioning the survey to ACTIVE. Two blockers can produce 409 STATE_CONFLICT:
UNDECLARED_TAGQuestion text references a tag that is not declared. Save-time validation also rejects this; it can only reach launch if the data was inserted out-of-band.
NO_FALLBACK_FOR_PUBLIC_LINKThe survey has a public link enabled and at least one required tag has no fallback. Anonymous traffic would see blanks; set a fallback or remove the public link.
Per-recipient data quality is intentionally not a launch blocker. If some pending invites are missing a required tag value, the survey can still launch. Those recipients see the fallback (or blank). The Distribute tab shows a soft-warning banner with the missing-invite count instead.
Contact.properties, but Revuloop never writes back to the contact record.SurveyMergeTag continues to function with libraryTagId nulled out.Existing webhook events (RESPONSE_SUBMITTED, etc.) deliver the full Response object. The sourceDetails.mergeContext snapshot is included automatically, no new event types and no opt-in required. Subscribers who currently parse sourceDetails.demographics can extend their handlers to read sourceDetails.mergeContext using the same pattern.
| Variable | Purpose |
|---|---|
SHARE_LINK_SECRET | HMAC secret for share-link signing/verification. Generate with openssl rand -hex 32. 32+ characters required. |