Free-product program (QuestX)
Turn foot traffic into engagement: the kiosk's attract loop advertises "finish tasks / win the game → free product", the shopper scans the QR on the machine, earns a one-time code on their phone, and the code vends a free product from that exact machine — booked as a $0 promo order the operator can account for.
(from /apk/getAd) S->>K: scan QR → GET /play?machine=NNN K-->>S: offer page (eligible products, quest list) S->>Q: complete quests / win game Q->>K: POST /play/api/questx/completion (x-kiosk-secret) K-->>S: one-time code KX-XXXXXXXX (machine-bound) S->>M: enters code at the discount screen M->>K: POST /apk/validDiscountCode K-->>M: data "0" → zero-price vend M->>K: POST /apk/createOrder K-->>M: $0 promo order (stock −1, retail value kept)
Shopper flow
- QR on the kiosk encodes
https://api.kiosk-x.ai/play?machine=<machineNo>. - The
/playpage shows the offer, the machine's eligible reward products (in-stock aisles priced within the program'smaxPricecap), and two ways to earn: - Real QuestX quests — hand-off to the QuestX offerwall
(
https://quest-x.ai/quests/offerwall?iv=1&machine=<machineNo>). When QuestX is reachable the page lists the machine's live quest feed (server-side proxy ofGET /api/kiosk/{kioskId}/cachewithx-kiosk-secret). QuestX-claimed vouchers work at the kiosk exactly as before (the original bridge). - Demo task set / demo game — a clearly-labeled sandbox path behind the
same interface (
KIOSKX_PLAY_DEMO_TASKS, default on). Task progression is tracked server-side; the credit only mints when every task is done. - Completion mints a one-time code (
KX-XXXXXXXX), bound to that machine, valid forKIOSKX_PLAY_CREDIT_TTL(24 h default).
Redemption (device protocol)
The shopper enters the code in the vending UI's discount screen
(Reyeah DiscountPOP). The firmware calls POST /apk/validDiscountCode:
KX-codes are validated locally: single-use (replay refused), machine-bound (machine A's code refused at machine B), expiry enforced.- Any other code falls through to the QuestX voucher bridge
(
POST /api/kiosk/voucher/consume-for-machine,x-kiosk-secret).
Either success returns data: "0" (zero payable) and arms a promo
entitlement on the machine: the next createOrder books ONE unit of the
first cap-eligible item as a $0 promo sale — stock decremented, revenue 0,
retail value and attribution (code, source, program) kept on the order.
The age gate still applies to age-restricted vends.
Operator control & accounting
| What | How |
|---|---|
| Enable/disable per machine | PUT /api/v1/machines/{machineNo}/free-product-program {"enabled": true, "maxPrice": 10, "fundingSourceId": "<fs-uuid>"} (app/routes/play.py:393-434) |
| Inspect program | GET /api/v1/machines/{machineNo}/free-product-program (app/routes/play.py:383-390) |
Enabling requires a funding source
A program can only be active while it references a funding source.
STORE.set_free_product_program raises ValueError("A funding source is
required to activate the free-product program (spec §4: sponsor, operator
budget, or platform)") when enabled: true is requested with no
fundingSourceId — either omitted on a machine that never had one, or
explicitly cleared while enabled (app/store.py:5290-5300). The route turns
that into HTTP 400 with the same message (app/routes/play.py:421-426).
fundingSourceId references a FundingSource in the qr-studio registry
(sponsor_brand | operator_budget | platform; request model
ProgramUpdateRequest, app/routes/play.py:367-373). Create one there
(POST /v1/funding-sources) and pass its id. When QR_STUDIO_BASE_URL is
unset (sandbox/dev) the id is still required but the remote availability check
is skipped (app/config.py:527-543, app/funding.py). The seeded demo machine
uses KIOSKX_PLAY_DEMO_FUNDING_SOURCE_ID (default fs-demo-operator-promo).
Optional campaignId rides alongside for attribution.
Enabling auto-registers the machine in QuestX
When the PUT leaves the program enabled, the route calls
questx_link.ensure_kiosk_registered(machineNo) (app/routes/play.py:429-430),
which POSTs QuestX POST /api/kiosk/register with x-kiosk-secret
(app/questx_link.py:83-125). QuestX upserts on hardwareId = machine number,
so re-enabling refreshes the row rather than duplicating it. Before this, the
kiosk row had to be created by hand in QuestX admin, and a freshly enabled
machine showed "This machine isn't linked to QuestX yet" (seen on
866903013700033, 2026-09-03).
Failure model — enrichment, never a blocker (questx_link.py:17-22): the
program still enables when QuestX is unconfigured or down. The one refusal:
a machine with no real lat/lng is not registered (QuestX geo-fences quest
check-ins), reported as machine-has-no-coordinates (registration_payload,
:55-61, 94-99). Set the machine's coordinates in Operator X first, then PUT
again.
Response shape
GET returns the normalized program (STORE.free_product_program,
app/store.py:5254-5267):
{"machineNo": "…", "enabled": true, "maxPrice": 10.0,
"fundingSourceId": "fs-…", "campaignId": null}
PUT returns the same object plus a questx block when the program is
enabled (app/routes/play.py:427-434; shape from ensure_kiosk_registered,
questx_link.py:86-88):
{"…": "…",
"questx": {"linked": true, "kioskId": "qx_…", "reason": null}}
reason on failure is one of questx-unconfigured,
machine-has-no-coordinates, questx-unreachable, questx-refused:<status>,
questx-refused:no-id (questx_link.py:36-39, 111-122). A disabled program
carries no questx block.
| Giveaway report | GET /api/v1/play/report — redemption counts, retail value given away, per-machine breakdown, credit funnel |
| Orders summary | GET /api/v1/orders/summary now carries a promoGiveaways block; giveaways are excluded from paid-revenue buckets |
Enabling a machine also injects two house creatives ("Play & win" /
"Finish tasks", 1080×1920, S3-hosted) into its /apk/getAd attract loop —
after sold inventory (direct-sold + programmatic), ahead of the generic
fixture screensaver.
QuestX integration status
QUESTX_API_BASE+QUESTX_KIOSK_DEVICE_SECRETconfigure both directions: outbound voucher consume (existing bridge) and the inbound completion webhookPOST /play/api/questx/completion(same shared secret, constant-time compared).- QuestX does not yet push completion webhooks itself; until that lands on the QuestX side, real-quest earners claim a QuestX voucher (works today), and the webhook + demo paths exercise the Kiosk-X-minted credit flow.
Env
| Variable | Default | Purpose |
|---|---|---|
KIOSKX_PLAY_CREDIT_TTL |
86400 |
Credit code lifetime (seconds) |
KIOSKX_PLAY_ENTITLEMENT_TTL |
600 |
validated-code → createOrder window |
KIOSKX_PLAY_MAX_PRICE |
20.0 |
default reward price cap |
KIOSKX_PLAY_DEMO_TASKS |
true |
demo task set on the /play page |
KIOSKX_PLAY_DEMO_FUNDING_SOURCE_ID |
fs-demo-operator-promo |
funding source the seeded demo machine's program references (app/config.py:541-543) |
QUESTX_WEB_BASE |
https://quest-x.ai |
shopper hand-off links |
QUESTX_API_BASE |
(unset → link reports questx-unconfigured) |
QuestX API for voucher consume, quest feed, and POST /api/kiosk/register (app/config.py:489) |
QUESTX_KIOSK_DEVICE_SECRET |
— | x-kiosk-secret for all of the above (app/config.py:490-491) |
QR_STUDIO_BASE_URL / QR_STUDIO_INTERNAL_TOKEN |
(unset → local funding rules only) | FundingSource registry + redemption cost ledger (app/config.py:533-535) |
See also: Redemption protocol (the voucher path), Screen ads & sponsorships (attract-loop economics).