Cashier — overview
Cashier is an operational account (operator_type=cashier), created by
management and scoped to a specific workspace — see Management →
Operators (PDV and cashier).
Authentication
| Endpoint | What it does |
|---|---|
POST /v1/auth/cashier/login | Login with the username/password generated by management. Sets wallet_cashier_access_token, wallet_cashier_refresh_token, wallet_cashier_csrf_token. |
POST /v1/auth/cashier/refresh | Rotates the refresh token. |
POST /v1/auth/cashier/logout | Revokes the session and clears the wallet_cashier_* cookies. |
The cashier JWT follows the same shape as the PDV's — swap operator_type
for "cashier". See PDV → overview for the full claims
example.
Finding or registering a participant
Two global routes (wallet-control-api), with no eventId because
identity is global — balance/wallet live in the region:
| Endpoint | What it does |
|---|---|
GET /v1/cashier/participants/by-phone/{phone} | Read-only lookup, never creates anything. 404 PARTICIPANT_NOT_FOUND if the phone has no profile yet. |
POST /v1/cashier/participants | Finds or creates by phone. If new, creates the profile without auth_user_id (they haven't installed the app or confirmed an OTP yet — that happens later, automatically, the first time they confirm an OTP for the same phone). Always returns an activationAuthorization: a signed, single-use authorization valid only for this participant and the cashier's event. |
- curl
- JavaScript
- Python
curl -X POST http://127.0.0.1:8787/v1/cashier/participants \
-H "Authorization: Bearer <cashier accessToken>" \
-H "Content-Type: application/json" \
-d '{ "phone": "+5511999999999" }'
const response = await fetch("http://127.0.0.1:8787/v1/cashier/participants", {
method: "POST",
headers: {
Authorization: `Bearer ${cashierAccessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ phone: "+5511999999999" }),
});
const { userId, displayName, profileComplete, activationAuthorization } = await response.json();
import requests
response = requests.post(
"http://127.0.0.1:8787/v1/cashier/participants",
headers={"Authorization": f"Bearer {cashier_access_token}"},
json={"phone": "+5511999999999"},
)
participant = response.json()
Looking up and activating the wallet on the participant's behalf
Two regional routes (wallet-api, /v1/events/{eventId}/...):
| Endpoint | What it does |
|---|---|
GET /v1/events/{eventId}/cashier/wallets/{userId} | Read-only lookup: this participant's wallet balance and status in this event. 404 WALLET_NOT_FOUND if they haven't activated yet. |
POST /v1/events/{eventId}/cashier/wallets/activate | Creates the wallet, using the activationAuthorization obtained in the previous step. Idempotent. The authorization can only be used once — even if activation fails for another reason, it's consumed; request a new one on error. |
GET /v1/events/{eventId}/cashier/wallets/{userId}/statement | Paginated statement of this wallet's movements (recharges today; sales/refunds once those exist). |
curl -X POST http://127.0.0.1:8787/v1/events/{eventId}/cashier/wallets/activate \
-H "Authorization: Bearer <cashier accessToken>" \
-H "Content-Type: application/json" \
-d '{ "userId": "<userId returned in the previous step>", "activationAuthorization": "<token returned in the previous step>" }'
Statement
curl "http://127.0.0.1:8787/v1/events/{eventId}/cashier/wallets/{userId}/statement?limit=20&sortDirection=desc" \
-H "Authorization: Bearer <cashier accessToken>"
{
"items": [
{
"entryId": "018f26d7-...",
"transactionId": "018f26d7-...",
"type": "recharge",
"accountType": "WALLET_AVAILABLE",
"amountMinor": 5000,
"occurredAt": "2026-09-03T16:30:29.377Z",
"paymentMethod": "cash",
"note": null,
"createdByOperatorUsername": "CX-55130771",
"createdByOperatorDescription": "Entrada Principal"
}
],
"nextCursor": null
}
createdByOperatorUsername/createdByOperatorDescription identify who
performed the movement (the cashier, or the PDV in the future) — they're
snapshotted onto the ledger entry at the moment it happens, so they keep
showing who the operator was at the time even if the account gets renamed
later. Both are null when there's no operator, or when the account has no
description set.
Cursor-paginated (not numbered pages): when nextCursor comes back
non-null, call again with ?cursor=<value> for the next batch — keep the
same sortDirection/filters as the original call, or the API rejects with
WALLET_STATEMENT_CURSOR_SORT_MISMATCH.
Available filters, all optional and combinable:
| Parameter | What it does |
|---|---|
type | Only one movement type (today only recharge exists). |
occurredAfter / occurredBefore | Date range, ISO 8601, inclusive on both ends. |
sortDirection | asc or desc (default: desc, newest first). |
limit | 1 to 100 (default 50). |
amountMinor is signed — positive is a credit (money coming in), negative
is a debit. paymentMethod/note only appear on recharge entries; they'll
be null for other movement types in the future.
Top-up
| Endpoint | What it does |
|---|---|
POST /v1/events/{eventId}/cashier/topups | Recharges the participant's wallet. Always immediate — there's no external payment confirmation in this phase, the cashier just declares how the money arrived. |
paymentMethod is free text, not a fixed list — "cash",
"credit_card", "pix", or whatever category the organization uses.
Requires the Idempotency-Key header: repeating the same key returns the
same result, without crediting twice — even if both calls arrive at the same
time, they never run the recharge in parallel. If a second call with the
same key arrives while the first is still in flight, it's rejected with
409 IDEMPOTENCY_KEY_IN_PROGRESS (rather than waiting) — retry shortly,
it's not a permanent error.
- curl
- JavaScript
- Python
curl -X POST http://127.0.0.1:8787/v1/events/{eventId}/cashier/topups \
-H "Authorization: Bearer <cashier accessToken>" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: <client-generated uuid>" \
-d '{ "userId": "<participant userId>", "amountMinor": 5000, "paymentMethod": "cash" }'
const response = await fetch(`http://127.0.0.1:8787/v1/events/${eventId}/cashier/topups`, {
method: "POST",
headers: {
Authorization: `Bearer ${cashierAccessToken}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({ userId, amountMinor: 5000, paymentMethod: "cash" }),
});
const { availableAmountMinor } = await response.json();
import requests
import uuid
response = requests.post(
f"http://127.0.0.1:8787/v1/events/{event_id}/cashier/topups",
headers={
"Authorization": f"Bearer {cashier_access_token}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={"userId": user_id, "amountMinor": 5000, "paymentMethod": "cash"},
)
result = response.json()
Printed QR credential with a PIN
For a participant without a phone/app:
| Endpoint | What it does |
|---|---|
POST /v1/events/{eventId}/cashier/wallet-credentials/printed-qr | Issues a revocable credential linked to the wallet. The system generates a random PIN and returns it in the response body exactly once — it's never stored in plaintext anywhere, only its hash. Reissuing automatically revokes the previous credential — a wallet never has more than one active at a time. |
There's no standalone PIN-reset endpoint that keeps the same QR. QR and PIN are two independent factors — the PDV reads the QR to find the credential, then asks for the PIN to validate that specific credential. One leaking alone is useless; only both together are a problem. So whenever there's any suspicion (PIN seen, card lost, whatever), the answer is always to reissue everything — there's no way to be sure only one of the two leaked. Even a plain "participant forgot their PIN," with no suspicion at all, also goes through reissuing (it's the only option available today).
The printed QR cannot be used to pay yet — the PDV doesn't have the read/consume side of this credential implemented yet.
My own operations
GET /v1/events/{eventId}/cashier/operations — the mirror image of the
statement above: instead of every movement on one wallet, it's every
operation you performed, across however many wallets you touched.
Always self-service — there's no parameter to see another cashier's
activity, only your own, identified from your own token.
curl "http://127.0.0.1:8787/v1/events/{eventId}/cashier/operations?limit=20" \
-H "Authorization: Bearer <cashier accessToken>"
{
"items": [
{
"transactionId": "018f26d7-...",
"type": "recharge",
"userId": "018f26d7-...",
"walletId": "018f26d7-...",
"amountMinor": 5000,
"occurredAt": "2026-09-03T16:30:29.377Z",
"paymentMethod": "cash",
"note": null,
"createdByOperatorUsername": "CX-55130771",
"createdByOperatorDescription": "Entrada Principal"
}
],
"nextCursor": null
}
Here createdByOperatorUsername/createdByOperatorDescription are always
your own — this endpoint is self-service.
Same filters/pagination as the wallet statement (type, occurredAfter,
occurredBefore, sortDirection, limit, cursor), plus one more:
userId, to filter down to operations performed on one specific
participant.
Important difference: occurredAfter has a ceiling here — the API
never shows anything older than 48 hours, no matter what you ask for.
It's not a default you can work around; it's a real limit, enforced by the
server. It exists because there's no cashier shift concept yet — with no
formal "shift start" to anchor against, this fixed window keeps a cashier
session from browsing a huge, unnecessary amount of history.
This endpoint is for identification only — it's meant for you to check what you did (e.g. reconciling against the physical cash drawer at the end of the day), there's no recharge reversal action available from here (yet).
What doesn't exist yet
- PDV-side verification/consumption of the printed QR credential (issuance and PIN reset already exist; the PDV still can't use it in a payment).
- Cashier shift (opening with an initial cash amount, closing with
reconciliation) — today, recharge auditing uses the JWT's
operatorAccountIddirectly, with no shift/physical cash register provisioned.