Survey API and receipt embed
The ReceiptRoller Survey feature lets a store collect responses via QR / link / digital receipt embed and optionally hand back a coupon. This article covers the public consumer API, the receipt embed payload, and the audience-targeting rules a store can attach to a campaign.
Public URL
Every survey campaign gets a 10-character URL-safe token. The public landing is:
https://{host}/s/{token}
The host comes from Survey:UniversalLinkHost in config; falls back to the request host. Native apps claim /s/* via Universal Link / App Link (entries are added to apple-app-site-association and assetlinks.json). LINE in-app browser users are 302'd to the LIFF URL with ?t={token} appended; everyone else lands on the inline web fallback form.
Consumer API
Three endpoints, all under /api/v1/surveys. Preview and submit are [AllowAnonymous] so a LIFF or web fallback can call them without a bearer token; the server gates non-anonymous campaigns with an auth_required status.
GET /api/v1/surveys/campaign/{token}
Returns the campaign metadata and full question list. Used by the client to render the form before the user starts answering.
{
"status": "ok",
"campaign": {
"campaignId": "...",
"storeId": "...",
"title": "...",
"description": "...",
"validFrom": "2026-04-01T00:00:00Z",
"validUntil": "2026-05-31T23:59:59Z",
"isAnonymous": true,
"maxResponsesPerUser": 1,
"hasReward": true
},
"questions": [
{ "questionId": "...", "order": 0, "type": "single_choice",
"text": "...", "isRequired": true, "optionsJson": "[\"A\",\"B\"]" }
]
}
Possible status values: ok, expired, inactive, unknown_token.
POST /api/v1/surveys/responses
Submit answers. Anonymous callers must send a stable deviceId; non-anonymous campaigns require a bearer token.
POST /api/v1/surveys/responses
Content-Type: application/json
{
"token": "...",
"answers": [
{ "questionId": "...", "value": "A" },
{ "questionId": "...", "value": "5" },
{ "questionId": "...", "value": "OptionA,OptionC" }
],
"deviceId": "anon-..."
}
Response uses a status discriminator the client branches on:
| Status | Meaning |
|---|---|
recorded | Saved successfully. couponIssuedId populated when the campaign has a reward and the coupon validates. |
response_limit | User already submitted maxResponsesPerUser times. |
expired | Outside the validity window. |
inactive | Campaign archived. |
unknown_token | Token not found. |
validation_error | Required field empty or out of range. validationErrors dict keyed by questionId. |
auth_required | Campaign requires a signed-in user; client should launch sign-in. |
Multi-choice answer encoding
For multi_choice questions the client may send either a comma-joined string ("A,C") or a JSON array string ("[\"A\",\"C\"]"). Both are accepted by the analytics aggregator.
GET /api/v1/surveys/me
Bearer-only. Returns the caller's own response history (response id, campaign id, store id, completed-at, coupon-issued id).
Digital receipt embed
When a store has an active campaign with showOnDigitalReceipt = true and its targeting rules match the receipt context, the transaction-receipt API attaches a survey object to the receipt payload:
GET /api/v1/rx/{claimToken}
{
"claimToken": "...",
"storeName": "...",
"transaction": { ... },
"survey": {
"campaignId": "...",
"title": "...",
"description": "...",
"url": "https://{host}/s/{token}",
"hasReward": true
}
}
The survey field is null when no campaign matches — the client just hides the section. Failures in the embed lookup never break the receipt response.
Audience targeting rules
Each campaign can attach a JSON document that filters which receipts get the embed. The document is a flat AND-list — a receipt only matches when every rule matches. Empty / missing document = always match.
{
"rules": [
{ "field": "total_amount", "op": "gte", "value": 1000 },
{ "field": "item_count", "op": "gte", "value": 3 },
{ "field": "category", "op": "eq", "value": "drinks" },
{ "field": "product_name", "op": "contains", "value": "coffee" },
{ "field": "time_of_day", "op": "gte", "value": 18 },
{ "field": "day_of_week", "op": "eq", "value": "fri" },
{ "field": "customer_visit_count", "op": "gte", "value": 2 }
]
}
Fields
| Field | Source | Type |
|---|---|---|
total_amount | Receipt total | decimal |
item_count | Number of line items | int |
category | Any line item's category | string |
product_name | Any line item's product name | string |
time_of_day | Hour-of-day of the transaction (0-23) | int |
day_of_week | 3-letter english abbrev (mon...sun) | string |
customer_visit_count | Cumulative visit count for this user (lazy-loaded) | int |
Operators
eq— exact match (string or number)gt/gte/lt/lte— numeric comparisoncontains— case-insensitive substring (string fields and line-item arrays)
Fail-closed semantics
Malformed JSON, unknown fields, unknown operators, or missing values cause the rule (and therefore the whole match) to fail. This is intentional: better to silently not embed a survey than to embed it on the wrong receipts.
Coupon reward
Setting RewardCouponId on a campaign makes SubmitResponseAsync resolve the coupon at submission time. The coupon must be active and within its own validity window; if so, the response row is stamped with the coupon id and the result DTO returns it as couponIssuedId. Coupons in ReceiptRoller are store-level templates — there is no per-user grant model. The consumer client treats the returned id as the redeemable code.
Per-user dedup
Each campaign has a maxResponsesPerUser setting (default 1). The service enforces the cap by counting responses keyed on either userId (signed-in submissions) or deviceId (anonymous submissions). The web fallback form generates a stable device id in localStorage; LIFF / native clients should send their own.
Universal Link / App Link
The /s/* path is included in apple-app-site-association and assetlinks.json alongside the existing terminal QR (/r/*), transaction QR (/rx/*), and check-in (/c/*) paths. Native apps that claim those paths receive the survey URL directly from the OS and can call the consumer API without going through the web landing.
Engineering reference
Full design spec, including phase-by-phase PR list and known v2 deferrals, lives on the engineering wiki: Engineering / Survey Feature — Design Spec.