Pay via QR or NFC
The PDV declares an amount and generates a payload — a QR on screen or transmitted over NFC, either way, the app treats both the same. The participant's app reads that payload, confirms the payment from their own app (login is already the authentication factor — no PIN, unlike the cashier's printed QR flow, which has no login behind it at all).
PDV declares the amount
→ PDV generates the payload (QR/NFC) with an opaque token
→ participant's app reads the payload
→ app resolves the token on the server (shows amount + PDV before anything else)
→ participant confirms
→ app approves the payment (debits the wallet)
The payload doesn't contain the amount — just event and a single-use
opaque token:
wallet://payment?v=1&event=<eventId>&intent=<token>
The app extracts eventId and token from this payload; the real amount
only ever comes from the resolve step below — never trust anything other
than the server's own response to decide how much will be charged.
1. Resolve (before confirming)
| Endpoint | What it does |
|---|---|
GET /v1/events/{eventId}/wallet/payment-intents/{token} | Read-only — never charges anything. Returns the amount and which PDV declared it, to render the confirmation screen. |
- curl
- JavaScript
- Python
curl "http://127.0.0.1:8787/v1/events/{eventId}/wallet/payment-intents/{token}" \
-H "Authorization: Bearer <participant accessToken>"
const response = await fetch(
`http://127.0.0.1:8787/v1/events/${eventId}/wallet/payment-intents/${token}`,
{ headers: { Authorization: `Bearer ${accessToken}` } },
);
const { amountMinor, status, operatorUsername, operatorDescription } = await response.json();
import requests
response = requests.get(
f"http://127.0.0.1:8787/v1/events/{event_id}/wallet/payment-intents/{token}",
headers={"Authorization": f"Bearer {access_token}"},
)
intent = response.json()
{
"intentId": "018f26d7-8a7b-7e7d-8c96-4f0b8d0f2b13",
"amountMinor": 2500,
"status": "pending",
"operatorUsername": "PDV-00184723",
"operatorDescription": "Entrada Principal",
"expiresAt": "2026-09-09T12:05:00.000Z"
}
404 PAYMENT_INTENT_NOT_FOUND if the token doesn't exist (or expired a
while ago and was cleaned up) in this event.
2. Approve (the participant's "Confirm" tap)
| Endpoint | What it does |
|---|---|
POST /v1/events/{eventId}/wallet/payment-intents/{token}/approve | Debits the authenticated participant's wallet by the intent's amount, credits the event's EVENT_REVENUE. |
Requires the Idempotency-Key header — same mechanism already documented
for the cashier's top-up: repeating the same
key returns the same result without charging again (replayed: true in
the response, meaning nothing was debited just now).
- curl
- JavaScript
- Python
curl -X POST "http://127.0.0.1:8787/v1/events/{eventId}/wallet/payment-intents/{token}/approve" \
-H "Authorization: Bearer <participant accessToken>" \
-H "Idempotency-Key: <client-generated uuid>"
const response = await fetch(
`http://127.0.0.1:8787/v1/events/${eventId}/wallet/payment-intents/${token}/approve`,
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Idempotency-Key": crypto.randomUUID(),
},
},
);
const { availableAmountMinor, replayed } = await response.json();
import requests
import uuid
response = requests.post(
f"http://127.0.0.1:8787/v1/events/{event_id}/wallet/payment-intents/{token}/approve",
headers={
"Authorization": f"Bearer {access_token}",
"Idempotency-Key": str(uuid.uuid4()),
},
)
result = response.json()
{
"walletId": "018f26d7-...",
"eventId": "018f26d7-...",
"availableAmountMinor": 16750,
"replayed": false
}
What can go wrong, and why:
| Code | Situation |
|---|---|
404 PAYMENT_INTENT_NOT_FOUND | Invalid token, or one from a different event. |
404 WALLET_NOT_FOUND | You haven't activated a wallet in this event yet. |
409 PAYMENT_INTENT_ALREADY_RESOLVED | Someone already approved, declined, or the intent already expired. Each intent can only ever be resolved once. |
409 PAYMENT_INTENT_EXPIRED | Too much time (short, a few minutes) passed with nobody approving. Ask the PDV for a new QR. |
409 INSUFFICIENT_BALANCE | Your wallet doesn't have enough balance. |
409 IDEMPOTENCY_KEY_IN_PROGRESS | An approval with this same key is already in flight; retry shortly. |
Unlike the cashier's printed QR, there's no PIN here — the app's own login is already the factor proving who's paying. That's exactly why the explicit confirmation in step 2 is mandatory: the app never approves on its own, without the participant seeing the amount and tapping confirm.