Survey API and receipt embed

survey api receipt targeting liff

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:

StatusMeaning
recordedSaved successfully. couponIssuedId populated when the campaign has a reward and the coupon validates.
response_limitUser already submitted maxResponsesPerUser times.
expiredOutside the validity window.
inactiveCampaign archived.
unknown_tokenToken not found.
validation_errorRequired field empty or out of range. validationErrors dict keyed by questionId.
auth_requiredCampaign 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

FieldSourceType
total_amountReceipt totaldecimal
item_countNumber of line itemsint
categoryAny line item's categorystring
product_nameAny line item's product namestring
time_of_dayHour-of-day of the transaction (0-23)int
day_of_week3-letter english abbrev (mon...sun)string
customer_visit_countCumulative visit count for this user (lazy-loaded)int

Operators

  • eq — exact match (string or number)
  • gt / gte / lt / lte — numeric comparison
  • contains — 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.

Published: 2026-04-25 Updated: 2026-07-05