Age & identity verification (Didit QR gate)
An optional, per-machine gate: when enabled, a buyer must complete an identity/age verification on their own phone — QR scan → Didit's hosted document + liveness flow — before a purchase can complete. Default is OFF: machines without the flag behave exactly as before.
The verification provider is the org's existing Didit stack
(https://didit.me, v3 sessions API) — the same application the
quests-economy KYC provider and the admin portal's didit-mcp tooling use.
Kiosk-X talks to it through app/didit_client.py.
Enabling it (Operator X)
Machine detail → Require age/ID verification (web console and the Operator X app), or directly:
curl -s -X PATCH https://api.kiosk-x.ai/api/v1/machines/{machineNo} \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"ageVerification": {"enabled": true, "minAge": 21, "appliesTo": ["all"]}}'
| Field | Meaning |
|---|---|
enabled |
The gate. Default false — nothing changes until you flip it. |
minAge |
Age threshold buyers must prove (default 21). |
appliesTo |
["all"] (default) or product verticals, e.g. ["alcohol","vape"] — only orders containing those categories are gated. |
failOpen |
Default false = fail closed: if the verification service is unreachable, restricted sales are blocked (and the failure is ledgered + the operator alerted). Opt into true to let sales proceed during an outage — every bypass is still ledgered as an error event. |
Machines stocking restricted verticals (vape / alcohol / tobacco / energy)
get an auto-suggestion to enable, surfaced on the machine detail
(GET /api/v1/machines/{machineNo}/age-verification → suggestion).
The buyer experience
Scan & Pay (one QR, verify → pay): the kiosk's payment QR routes the
buyer to the verification step first; after the pass the same phone flow
continues straight into Stripe Checkout. No second QR. A passed
verification also sets a short-lived signed cookie (machine + threshold
+ expiry, KIOSKX_VERIFY_TOKEN_TTL, default 15 min) so a multi-item run of
purchases verifies once.
Card reader: the buyer scans the machine's verification QR
(GET /device/qr/verify/{machineNo}.png, served only while the gate is ON;
Operator X shows/prints it), verifies on their phone, then pays at the
reader. The pass arms the machine's single-transaction verification — the
same store the on-machine ID scanner feeds.
Failure paths: underage or declined → a clear decline page and no
payment is ever taken (the Stripe session is only minted after a pass).
Didit unreachable → fail-closed page by default (see failOpen). Abandoned
mid-verify → the session expires (KIOSKX_VERIFY_SESSION_TTL, default 30
min).
What the card-reader path can and cannot enforce
The Nayax VPOS reader authorizes independently of the vending cloud (MDB to the VMC, cellular to Nayax — see Payments & Nayax). The honest enforcement boundary:
- What we block: the kiosk firmware asks this cloud for an order
(
/apk/createOrder, payType 3) before arming the reader. With the gate ON and no passed verification, that call fails with a clear "ID verification required — scan the verification QR" message, so the reader is never armed and no charge happens. - What we cannot block: hardware paths that bypass the kiosk app
entirely (e.g. a reader wired to vend on MDB without consulting the
cloud). On the shipped Reyeah firmware every card sale goes through
createOrder, so the gate holds there — but the cloud cannot physically withhold dispense from a VMC that never asks. That residual gap is hardware-dependent and stays documented here rather than papered over.
The existing per-product age gate (goods with minAge > 0 require the
on-machine ID scan, /apk/ageVerify) is unchanged and still applies when
the machine-level gate is off. When both apply, the phone verification
proves the higher threshold.
Didit wiring
Secrets ride the same seam as Stripe (env → k8s secret kiosk-x-secrets):
| Env | What |
|---|---|
KIOSKX_DIDIT_API_KEY |
Didit application API key (x-api-key). The org's key lives in the cluster secret didit-mcp-secret (ns aicart). |
KIOSKX_DIDIT_WORKFLOW_ID |
Verification workflow UUID (document + liveness + age). |
KIOSKX_DIDIT_WEBHOOK_SECRET |
HMAC secret for POST /verify/webhooks/didit (X-Signature / X-Signature-V2 / simple schemes all verified; unsigned → 401). Point Didit's webhook at https://api.kiosk-x.ai/verify/webhooks/didit. |
KIOSKX_DIDIT_BASE_URL |
Default https://verification.didit.me. |
Webhook delivery is idempotent (a replay never double-ledgers or re-opens a decided session) and the buyer's return page polls the decision as a fallback, so a lost webhook cannot strand a verified buyer.
No key configured (sandbox) → the flow serves a clearly-labelled simulated ID check — the same honesty pattern as scanpay's simulated pay page. It returns 404 the moment Didit is configured.
Privacy posture
- Stored per verification: pass/fail + threshold + timestamp + Didit
session ref. Never DOB, name, or document data — the DOB in a Didit
decision is used once, in memory, to compute the over-threshold boolean,
then dropped (
app/didit_client.extract_is_over). - The "verified" cookie carries machine + threshold + expiry, HMAC-signed — no identity.
- Identity documents live in Didit's vault under its retention policy; deleting the Didit session (via didit-mcp / the Didit dashboard) is the erasure story. Kiosk-X-side records are anonymous by construction, so there is nothing to erase here.
Operator visibility
- Machine detail (app + web): policy, pass/fail/error counts (total and last 24 h), recent events, the verification QR.
GET /api/v1/machines/{machineNo}/age-verification— everything above.- Assistant tool
get_age_verification_stats— "how are ID checks doing on the vape machine?". - Events are FLEET-tier (multi-replica safe) and carry
operatorEmailscoping.