On this page
Getting started
All API access happens over HTTPS from a single base URL. Every endpoint in this reference is relative to it, versioned under /v1.
Base URL
https://app.wallento.cz/apiCreate an API key
Every request needs a key. Sign in as a tenant admin, open Settings > API keys, and create one: give it a name and pick the scopes it needs, or bind it to a single location - a register token for one till, which restricts it to the pos scope and stamps every transaction it writes with that location automatically. The plaintext key (wlt_sk_...) is shown exactly once, at creation. If you lose it, revoke it and create a new one - it cannot be shown again.
Make your first request
The primary POS entry point resolves a customer's current loyalty state from a phone number or a wallet card token:
shell
curl https://app.wallento.cz/api/v1/customers/lookup \
-H "Authorization: Bearer wlt_sk_..." \
-H "Content-Type: application/json" \
-d '{"phone": "+420777123456"}'From here: Authentication covers the full scope list, or jump straight to the API reference for every endpoint.
Authentication
Every request carries a bearer token:
HTTP header
Authorization: Bearer wlt_sk_...The key determines the tenant and the scopes available to the request - the tenant is never sent anywhere else, there is nowhere to put one. An operation succeeds as soon as the key carries at least one of the scopes it requires.
| Scope | Grants |
|---|---|
pos | Customer lookup, writing transactions, reading one customer and their own history, and reading the program. The only scope a checkout terminal needs. |
customers:read | List and read customers |
customers:write | Create, update and delete customers |
program:read | Read the loyalty program configuration |
program:write | Change the loyalty program configuration |
locations:read | List locations |
locations:write | Create and update locations |
reports:read | Read transactions (for reconciliation) and the monthly stats report |
broadcast:send | Send a broadcast push to every customer with a synced wallet pass |
webhooks:manage | Register, list, delete and test webhook endpoints |
Location-bound keys
A key can optionally be bound to a single location - a register token for one till or POS terminal. A location-bound key is restricted to exactly the pos scope, and every transaction it writes is stamped with that location automatically; sending a different locationId in the body of POST /v1/transactions is rejected. Revoking one register never affects any other key.
Idempotency
POST /v1/customers and POST /v1/transactions require an Idempotency-Key header (1 to 255 characters, chosen by you). Every other POST accepts one but does not require it.
Replay the same key with the exact same request body, and the API returns the original response verbatim - with an Idempotent-Replay: true header - instead of repeating the side effect. The same key with a different body, or a key whose original request is still in flight, returns409 IDEMPOTENCY_CONFLICT. Keys are held for 24 hours.
Idempotency-Key (present but not 1-255 characters) always returns 400 VALIDATION_ERROR, not 409, on every POST that accepts the header; on POST /v1/customers and POST /v1/transactions, a missing one returns the same 400. Either way, fixing the mistake and retrying with the same key is never locked out by the earlier failed attempt.POST /v1/transactions layers a second, unrelated safety net on top: only the customer's immediately preceding transaction is checked, and when it is of the same resulting kind and was recorded less than 180 seconds ago, the request is rejected as a likely double-scan (409 DUPLICATE_TRANSACTION) unlessforce: true is set - an intervening transaction of a different kind (for example a purchase recorded between two stamps) clears the guard. That guard applies even when every request already carries its own fresh idempotency key - it protects against a cashier pressing the same button twice, not against a network retry.
Errors
Every error response has the same shape:
JSON
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": { }
}
}code is a stable, machine-readable identifier - integrate against code, never against message, which is meant for logs and may reword between releases. details, when present, is specific to code - the raw validation issues onVALIDATION_ERROR, or { existingCustomerId } on CUSTOMER_EXISTS.
| HTTP | Code |
|---|---|
| 400 | VALIDATION_ERROR |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED |
| 404 | NOT_FOUNDAlso returned for a resource that belongs to a different tenant - the API never confirms another tenant's data exists with a 403. |
| 409 | DUPLICATE_TRANSACTIONIDEMPOTENCY_CONFLICTCUSTOMER_EXISTSPROGRAM_LOCKEDWEBHOOK_LIMITCONFLICTThe last three are endpoint-specific - see each operation in the reference for exactly which it can return. |
| 422 | NO_PENDING_REWARDINSUFFICIENT_POINTSUNKNOWN_REWARDNEGATIVE_BALANCELoyalty-engine rejections, only on POST /v1/transactions - always a genuine business rule, never worth retrying as-is. |
| 429 | RATE_LIMITED |
Rate limits
Limits are enforced per API key, on a sliding window:
| Budget | Limit |
|---|---|
| Reads | 600 / minute |
| Writes | 300 / minute |
POST /v1/broadcasts | 2 / hour, per tenant |
GET /v1/customers?email=On top of the general read budget - this filter scans the tenant's entire customer set for an exact match. | 20 / minute, per tenant |
| Failed authentication attempts Per client IP, not per key - once an IP crosses this ceiling, further bad or wrong-scope bearer tokens get a 429 in place of the usual 401/403. A request that authenticates successfully never counts against it, however many valid keys share the IP. | 1200 / minute, per IP |
Authenticated responses and every 429 carry these headers - a 401 or 403 never does:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed in the current window. |
X-RateLimit-Remaining | Requests remaining in the current window. |
X-RateLimit-Reset | Unix timestamp, in seconds, when the window resets. |
Retry-After | Seconds to wait before retrying. Present only on a 429. |
A 401 or 403 itself never carries these headers - but a key that fails to resolve can still trip the failed-authentication budget above first, which returns a header-bearing 429 in its place.
Webhooks
Register an HTTPS endpoint with POST /v1/webhooks (webhooks:manage scope) to receive events as they happen:
| Event | Fires when | Payload data |
|---|---|---|
customer.created | A customer is registered - through this API, the public join page, or POS registration | { customer } |
customer.updated | A customer's identity fields change (PATCH /v1/customers/{id}) | { customer } |
customer.deleted | A customer is deleted | { customerId } |
transaction.created | Any transaction is recorded - this API, the staff scanner app, or an admin adjustment | { transaction, customer, rewardEarned, levelUp } |
broadcast.sent | A broadcast push finishes fanning out | { message, queued, failed } |
ping | Only sent by POST /v1/webhooks/{id}/test | { } |
transaction.created fires for every transaction, however it was written - this API, the staff scanner app, or an admin adjustment - which is what makes it useful for event-driven integrations. POST /v1/webhooks/{id}/test sends a one-off ping event through the exact same delivery path, so you can verify an endpoint and its signature check before relying on it.
Verifying a delivery
Each delivery is a POST of the event JSON with a signature header, computed as hex HMAC-SHA256 of ${t}.${rawBody} using the endpoint's ownwhsec_... secret - shown in full exactly once, at creation; every later response redacts it to the last 4 characters:
HTTP header
Wallento-Signature: t=<unix_ts>,v1=<hex hmac-sha256>Verify a delivery by recomputing the same HMAC over the raw bytes you received and comparing in constant time, and reject anything whose t is more than 5 minutes from your own clock:
Node.js
const crypto = require('node:crypto')
function verifyWallentoSignature(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
signatureHeader.split(',').map((part) => part.trim().split('='))
)
const timestamp = Number(parts.t)
if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) {
throw new Error('Signature timestamp missing or outside tolerance')
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex')
const received = Buffer.from(parts.v1 ?? '', 'hex')
const expectedBuf = Buffer.from(expected, 'hex')
if (received.length !== expectedBuf.length || !crypto.timingSafeEqual(received, expectedBuf)) {
throw new Error('Signature mismatch')
}
}
// rawBody must be the exact bytes received, before any JSON.parse
verifyWallentoSignature(rawBody, req.headers['wallento-signature'], endpointSecret)A delivery that fails or exceeds a 10 second timeout is retried on a backoff schedule and eventually moved to a dead letter queue once retries are exhausted. A slow or failing endpoint never blocks or reverts the write that triggered it - a tenant may register at most 5 endpoints.
API reference
Generated from docs/api/openapi.json (version 1.0.0) - every path and method below has a matching route handler, checked by an automated contract test on every change. For scopes, idempotency and the full error code table, see the guide above.
Customers
Lookup, registration, profile and history of a tenant's loyalty customers.
/v1/customers/lookupIdempotency-Key acceptedLook up a customer by card token or phone
The primary POS entry point: resolves a customer's current loyalty state from a wallet card token (the raw token, or the full card URL - both are accepted) or from a phone number. Scope: pos. Accepts Idempotency-Key but does not require it.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Optional on this endpoint. When present, a retried request with the same key and body replays the original response instead of repeating the side effect - see Idempotency in the API description. 1-255 chars |
Request body
| Field | Type | Description |
|---|---|---|
cardToken | string | The wallet card token (wlt_...) or the current scan token (scn_...) printed in the card's QR code, either as the bare token or as its full URL - https://app.wallento.cz/c/<token> or https://app.wallento.cz/s/<token>. A POS scanning a live card's QR always sends the scn_ shape; wlt_ still resolves for cards issued before the scan/install token split that have not been reissued yet. 1-300 chars |
phone | string | Any reasonably formatted phone number; normalized to E.164 server-side. 1-30 chars |
Example
{
"cardToken": "wlt_9pQ2xN4kTf7Lm1Rv"
}Responses
| Status | Code | Description |
|---|---|---|
| 200 | - | Customer found. |
| 400 | VALIDATION_ERROR | The request failed validation - a malformed body, an out-of-range or malformed query parameter, or a missing/malformed Idempotency-Key header on an endpoint that requires one. `details` holds the underlying validation issues when the failure is body/query shape. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 404 | NOT_FOUND | No resource with this id exists for the calling tenant. Also returned - deliberately indistinguishable from a genuinely missing id - when the id belongs to a DIFFERENT tenant: the API never confirms another tenant's data exists with a 403. |
| 409 | IDEMPOTENCY_CONFLICT | This Idempotency-Key was already used with a different request body, or the original request is still in flight. |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
409
{
"error": {
"code": "IDEMPOTENCY_CONFLICT",
"message": "Idempotency-Key was already used with a different request body, or the original request is still in flight"
}
}Example request
shell
curl -X POST https://app.wallento.cz/api/v1/customers/lookup \
-H "Authorization: Bearer wlt_sk_..." \
-H "Idempotency-Key: a-unique-request-id" \
-H "Content-Type: application/json" \
-d '{"cardToken":"wlt_9pQ2xN4kTf7Lm1Rv"}'/v1/customersList customers
Cursor-paginated. phone and email are mutually exclusive filters; either one returns at most the matching customer(s) as a single, unpaginated page (no nextCursor, regardless of limit/cursor). Without a filter, returns a plain cursor-paginated page of every customer, newest first. Scope: customers:read. The email filter is metered by an additional, tighter per-tenant rate budget on top of the general read limit, since it scans the tenant's entire customer set to find an exact, case-insensitive match.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
limit | query | integer | 1-200 - default 50 |
cursor | query | string | Opaque pagination cursor taken from a previous response's `nextCursor`. A malformed value returns 400 VALIDATION_ERROR. |
phone | query | string | Exact phone match, normalized server-side before comparison. Mutually exclusive with `email`. |
email | query | string | Exact, case-insensitive email match. Mutually exclusive with `phone`. |
Responses
| Status | Code | Description |
|---|---|---|
| 200 | - | A page of customers, or the 0/1-item result of a phone/email filter. |
| 400 | VALIDATION_ERROR | The request failed validation - a malformed body, an out-of-range or malformed query parameter, or a missing/malformed Idempotency-Key header on an endpoint that requires one. `details` holds the underlying validation issues when the failure is body/query shape. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 404 | NOT_FOUND | No resource with this id exists for the calling tenant. Also returned - deliberately indistinguishable from a genuinely missing id - when the id belongs to a DIFFERENT tenant: the API never confirms another tenant's data exists with a 403. |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
Example request
shell
curl https://app.wallento.cz/api/v1/customers \
-H "Authorization: Bearer wlt_sk_..."/v1/customersIdempotency-Key requiredRegister a customer
Registers a customer from a POS or e-shop, deduplicated by phone number. Scope: customers:write. Requires Idempotency-Key. When notify is email (the default) and an email address is provided, the wallet card is emailed to the customer using the same flow as the public join page.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
Idempotency-Key* | header | string | Required on this endpoint. A caller-chosen string, unique per logical request, used to make retries safe - see Idempotency in the API description. 1-255 chars |
Request body
| Field | Type | Description |
|---|---|---|
name* | string | 2-100 chars |
phone* | string | 1-30 chars |
email | string | up to 200 chars |
marketingOptIn | boolean | default false |
notify | "email" | "none" | When email and an address is provided, the wallet card is emailed to the customer. default "email" |
Example
{
"name": "Jana Novakova",
"phone": "+420777123456",
"email": "jana.novakova@example.com",
"marketingOptIn": true,
"notify": "email"
}Responses
| Status | Code | Description |
|---|---|---|
| 201 | - | Customer created. |
| 400 | VALIDATION_ERROR | The request failed validation - a malformed body, an out-of-range or malformed query parameter, or a missing/malformed Idempotency-Key header on an endpoint that requires one. `details` holds the underlying validation issues when the failure is body/query shape. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 404 | NOT_FOUND | No resource with this id exists for the calling tenant. Also returned - deliberately indistinguishable from a genuinely missing id - when the id belongs to a DIFFERENT tenant: the API never confirms another tenant's data exists with a 403. |
| 409 | CUSTOMER_EXISTSIDEMPOTENCY_CONFLICT | Either a customer with this phone number already exists, or this Idempotency-Key was already used with a different request body (or is still in flight). |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
409 - Phone already registered
{
"error": {
"code": "CUSTOMER_EXISTS",
"message": "A customer with this phone number already exists",
"details": {
"existingCustomerId": "V1StGXR8_Z5j"
}
}
}409 - Idempotency-Key reused with a different body
{
"error": {
"code": "IDEMPOTENCY_CONFLICT",
"message": "Idempotency key was already used with a different request body, or the original request is still in flight"
}
}Example request
shell
curl -X POST https://app.wallento.cz/api/v1/customers \
-H "Authorization: Bearer wlt_sk_..." \
-H "Idempotency-Key: a-unique-request-id" \
-H "Content-Type: application/json" \
-d '{"name":"Jana Novakova","phone":"+420777123456","email":"jana.novakova@example.com","marketingOptIn":true,"notify":"email"}'/v1/customers/{cid}Get a customer
Scope: pos or customers:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
cid* | path | string | Customer id. |
Responses
| Status | Code | Description |
|---|---|---|
| 200 | - | Customer found. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 404 | NOT_FOUND | No resource with this id exists for the calling tenant. Also returned - deliberately indistinguishable from a genuinely missing id - when the id belongs to a DIFFERENT tenant: the API never confirms another tenant's data exists with a 403. |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
Example request
shell
curl https://app.wallento.cz/api/v1/customers/V1StGXR8_Z5j \
-H "Authorization: Bearer wlt_sk_..."/v1/customers/{cid}Update a customer's identity fields
Updates name, email and/or marketingOptIn - identity fields only, never loyalty state (stamps, points, level). phone cannot be changed through this API. Scope: customers:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
cid* | path | string | Customer id. |
Request body
| Field | Type | Description |
|---|---|---|
name | string | 2-100 chars |
email | string | A valid email address, or an empty string to clear it. up to 200 chars |
marketingOptIn | boolean |
Example
{
"email": "new-address@example.com"
}Responses
| Status | Code | Description |
|---|---|---|
| 200 | - | Customer updated. |
| 400 | VALIDATION_ERROR | The request failed validation - a malformed body, an out-of-range or malformed query parameter, or a missing/malformed Idempotency-Key header on an endpoint that requires one. `details` holds the underlying validation issues when the failure is body/query shape. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 404 | NOT_FOUND | No resource with this id exists for the calling tenant. Also returned - deliberately indistinguishable from a genuinely missing id - when the id belongs to a DIFFERENT tenant: the API never confirms another tenant's data exists with a 403. |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
Example request
shell
curl -X PATCH https://app.wallento.cz/api/v1/customers/V1StGXR8_Z5j \
-H "Authorization: Bearer wlt_sk_..." \
-H "Content-Type: application/json" \
-d '{"email":"new-address@example.com"}'/v1/customers/{cid}Delete a customer
Erases the customer (GDPR deletion) and best-effort revokes their wallet pass. Scope: customers:write. Irreversible.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
cid* | path | string | Customer id. |
Responses
| Status | Code | Description |
|---|---|---|
| 200 | - | Customer deleted. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 404 | NOT_FOUND | No resource with this id exists for the calling tenant. Also returned - deliberately indistinguishable from a genuinely missing id - when the id belongs to a DIFFERENT tenant: the API never confirms another tenant's data exists with a 403. |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
Example request
shell
curl -X DELETE https://app.wallento.cz/api/v1/customers/V1StGXR8_Z5j \
-H "Authorization: Bearer wlt_sk_..."/v1/customers/{cid}/transactionsList a customer's transaction history
Cursor-paginated, newest first. Scope: pos or customers:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
cid* | path | string | Customer id. |
limit | query | integer | 1-200 - default 50 |
cursor | query | string | Opaque pagination cursor taken from a previous response's `nextCursor`. A malformed value returns 400 VALIDATION_ERROR. |
Responses
| Status | Code | Description |
|---|---|---|
| 200 | - | A page of the customer's transactions. |
| 400 | VALIDATION_ERROR | The request failed validation - a malformed body, an out-of-range or malformed query parameter, or a missing/malformed Idempotency-Key header on an endpoint that requires one. `details` holds the underlying validation issues when the failure is body/query shape. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 404 | NOT_FOUND | No resource with this id exists for the calling tenant. Also returned - deliberately indistinguishable from a genuinely missing id - when the id belongs to a DIFFERENT tenant: the API never confirms another tenant's data exists with a 403. |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
Example request
shell
curl https://app.wallento.cz/api/v1/customers/V1StGXR8_Z5j/transactions \
-H "Authorization: Bearer wlt_sk_..."Transactions
The single write endpoint for loyalty activity, plus reconciliation reads.
/v1/transactionsList transactions for reconciliation
Every transaction for the tenant, cursor-paginated newest first, with optional date-range, location and type filters. Scope: reports:read.
from/to filter on the underlying score-sorted index server-side; locationId and type filter client-side against the already-fetched page. Because of this, a heavily filtered page can legitimately contain fewer than limit items while nextCursor is still present - keep paging until nextCursor is absent, do not stop merely because a page came back short.
Note type here is the STORED transaction kind (stamp, points_earn, redeem, adjust), not the action name POST /v1/transactions accepts: a spend action produces points_earn, and both redeem_stamp and redeem_points produce redeem.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
from | query | string | Inclusive lower bound, ISO 8601. Omit for no lower bound. |
to | query | string | Inclusive upper bound, ISO 8601. Omit for no upper bound. |
locationId | query | string | |
type | query | "stamp" | "points_earn" | "redeem" | "adjust" | Filters by the stored transaction kind - see the operation description for how this maps from the write endpoint's action names. |
limit | query | integer | 1-200 - default 50 |
cursor | query | string | Opaque pagination cursor taken from a previous response's `nextCursor`. A malformed value returns 400 VALIDATION_ERROR. |
Responses
| Status | Code | Description |
|---|---|---|
| 200 | - | A page of transactions. |
| 400 | VALIDATION_ERROR | The request failed validation - a malformed body, an out-of-range or malformed query parameter, or a missing/malformed Idempotency-Key header on an endpoint that requires one. `details` holds the underlying validation issues when the failure is body/query shape. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
Example request
shell
curl https://app.wallento.cz/api/v1/transactions \
-H "Authorization: Bearer wlt_sk_..."/v1/transactionsIdempotency-Key requiredRecord a stamp, spend or redemption
The API's single write endpoint - a thin facade over the same optimistic-concurrency commit, loyalty-engine dispatch and wallet-pass sync the staff scanner app uses. Scope: pos. Requires Idempotency-Key.
Resolve the customer with exactly one of customerId or cardToken. spentCzk is required when type is spend; rewardId is required when type is redeem_points. type must also match the tenant's own program - stamp/redeem_stamp on a points program, or spend/redeem_points on a stamps program, is rejected with 400 VALIDATION_ERROR rather than silently recording a no-op transaction. Only the customer's immediately preceding transaction is checked for a duplicate: when it is of the same resulting kind and was recorded less than 180 seconds ago, the request is rejected as a likely double-scan (409 DUPLICATE_TRANSACTION) unless force: true is set - an intervening transaction of a different kind (for example a purchase recorded between two stamps) clears the guard. This is a separate concern from the Idempotency-Key envelope above it, which protects against network retries; this guard protects against a cashier pressing the same button twice, each press carrying its own fresh key.
A location-bound key stamps its own locationId automatically; an unbound key may send any locationId belonging to its own tenant.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
Idempotency-Key* | header | string | Required on this endpoint. A caller-chosen string, unique per logical request, used to make retries safe - see Idempotency in the API description. 1-255 chars |
Request body
| Field | Type | Description |
|---|---|---|
customerId | string | 1-64 chars |
cardToken | string | The wallet card token (wlt_...) or the current scan token (scn_...) printed in the card's QR code, either as the bare token or as its full URL - https://app.wallento.cz/c/<token> or https://app.wallento.cz/s/<token>. Same lookup rules as CustomerLookupRequest.cardToken. 1-300 chars |
type* | "stamp" | "spend" | "redeem_stamp" | "redeem_points" | Must match the tenant's own program: stamp/redeem_stamp on a stamps program, spend/redeem_points on a points program. A mismatch returns 400 VALIDATION_ERROR. |
spentCzk | integer | Required when type is spend. 1-1000000 |
rewardId | string | Required when type is redeem_points; the id of one of the program's configured point rewards. at least 1 chars |
externalRef | string | Partner-supplied receipt/order id, echoed back on the transaction. 1-64 chars |
locationId | string | Only meaningful for an unbound key that wants to attribute this transaction to a specific location. A location-bound key stamps its own location automatically; sending a different id here is rejected. 1-64 chars |
force | boolean | Bypasses the 180 second duplicate-transaction guard. default false |
Example
{
"customerId": "V1StGXR8_Z5j",
"type": "stamp",
"externalRef": "receipt-10245"
}Responses
| Status | Code | Description |
|---|---|---|
| 201 | - | Transaction recorded. |
| 400 | VALIDATION_ERROR | The request failed validation - a malformed body, an out-of-range or malformed query parameter, or a missing/malformed Idempotency-Key header on an endpoint that requires one. `details` holds the underlying validation issues when the failure is body/query shape. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 404 | NOT_FOUND | No resource with this id exists for the calling tenant. Also returned - deliberately indistinguishable from a genuinely missing id - when the id belongs to a DIFFERENT tenant: the API never confirms another tenant's data exists with a 403. |
| 409 | DUPLICATE_TRANSACTIONIDEMPOTENCY_CONFLICTCONFLICT | The customer's immediately preceding transaction was of the same kind and was recorded very recently, and `force` was not set; this Idempotency-Key was already used with a different body (or is still in flight); or a rare optimistic-concurrency conflict that a straight retry resolves. |
| 422 | NO_PENDING_REWARDINSUFFICIENT_POINTSUNKNOWN_REWARDNEGATIVE_BALANCE | The loyalty engine rejected this transaction - always a genuine business rule, never a transient condition worth retrying as-is. |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
201 - Stamp awarded, card completed
{
"transaction": {
"id": "Qx8mLp2Kz9Rt",
"customerId": "V1StGXR8_Z5j",
"type": "stamp",
"amount": 1,
"spentCzk": null,
"rewardId": null,
"locationId": "Nk4pXz9Lm2Qr",
"actor": "api",
"externalRef": "receipt-10245",
"note": null,
"createdAt": "2026-08-09T14:32:10.000Z"
},
"customer": {
"id": "V1StGXR8_Z5j",
"name": "Jana Novakova",
"phone": "+420777123456",
"email": "jana.novakova@example.com",
"marketingOptIn": true,
"cardToken": "wlt_9pQ2xN4kTf7Lm1Rv",
"cardUrl": "https://app.wallento.cz/c/wlt_9pQ2xN4kTf7Lm1Rv",
"passState": "pending",
"visits": 9,
"firstTxAt": "2026-06-02T08:12:00.000Z",
"lastTxAt": "2026-08-12T09:41:02.000Z",
"originLocationId": "Nk4pXz9Lm2Qr",
"signupRef": "U008-1043",
"loyalty": {
"programType": "stamps",
"currentStamps": 0,
"pendingRewards": 1,
"pointsBalance": 0,
"lifetimeStamps": 9,
"lifetimePoints": 0,
"level": 1,
"levelName": "Gold"
},
"createdAt": "2026-06-01T09:00:00.000Z",
"registeredAt": "2026-06-01T09:00:00.000Z"
},
"rewardEarned": true,
"earnedPoints": null,
"levelUp": {
"name": "Gold",
"threshold": 9
}
}409 - Likely double-scan
{
"error": {
"code": "DUPLICATE_TRANSACTION",
"message": "A transaction of the same type was recorded very recently",
"details": {
"lastTxAgoSec": 4
}
}
}409 - Idempotency-Key reused with a different body
{
"error": {
"code": "IDEMPOTENCY_CONFLICT",
"message": "Idempotency-Key was already used with a different request body, or the original request is still in flight"
}
}409 - Optimistic-concurrency conflict - safe to retry
{
"error": {
"code": "CONFLICT",
"message": "Conflict, please retry"
}
}422 - redeem_stamp with nothing to redeem
{
"error": {
"code": "NO_PENDING_REWARD",
"message": "No pending reward to redeem"
}
}422 - redeem_points, not enough balance
{
"error": {
"code": "INSUFFICIENT_POINTS",
"message": "Not enough points for this reward"
}
}422 - rewardId does not exist on this program
{
"error": {
"code": "UNKNOWN_REWARD",
"message": "Unknown reward"
}
}422 - Would drive the points balance negative
{
"error": {
"code": "NEGATIVE_BALANCE",
"message": "Resulting balance would be negative"
}
}Example request
shell
curl -X POST https://app.wallento.cz/api/v1/transactions \
-H "Authorization: Bearer wlt_sk_..." \
-H "Idempotency-Key: a-unique-request-id" \
-H "Content-Type: application/json" \
-d '{"customerId":"V1StGXR8_Z5j","type":"stamp","externalRef":"receipt-10245"}'/v1/transactions/{txid}Get a transaction
Scope: reports:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
txid* | path | string | Transaction id. |
Responses
| Status | Code | Description |
|---|---|---|
| 200 | - | Transaction found. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 404 | NOT_FOUND | No resource with this id exists for the calling tenant. Also returned - deliberately indistinguishable from a genuinely missing id - when the id belongs to a DIFFERENT tenant: the API never confirms another tenant's data exists with a 403. |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
Example request
shell
curl https://app.wallento.cz/api/v1/transactions/Qx8mLp2Kz9Rt \
-H "Authorization: Bearer wlt_sk_..."/v1/transactions/reverseIdempotency-Key requiredUndo the loyalty effect of a voided receipt
Takes back what one receipt did to a customer's balance, after the till voided that receipt. Scope: pos. Requires Idempotency-Key.
The receipt is identified by the externalRef its transactions were recorded with, not by transaction id - one call undoes everything that receipt did, both the reward redemption and the points earned on it. Records exactly ONE adjust transaction carrying the net inverse, with a mandatory note: deliberately the same instrument a tenant admin uses by hand in the admin UI, because that hand correction is what this endpoint automates. That means it inherits the same semantics: the CURRENT balance moves, lifetimePoints and level do not, and the monthly report still counts the voided receipt as a visit.
Only transactions the customer still has outstanding are reversed. A second call for the same receipt returns 200 with alreadyReversed: true and changes nothing - a store-and-forward till whose queue outlives the 24 hour Idempotency-Key window must not be told a correction failed when it had in fact already landed.
If the customer has since spent the points, the correction is CLAMPED at a zero balance rather than rejected: by the time this call is made the money has already gone back over the counter. requestedPoints and appliedPoints differ in that case, clamped is true, and the shortfall is stated in the transaction's note.
Points programs only - on a stamps program the inverse of a redemption is a pending reward, which an adjustment cannot give back, so the request is rejected with 400 VALIDATION_ERROR instead of silently corrupting the stamp card. The lookup scans this customer's most recent transactions; a receipt older than that window returns 404 NOT_FOUND with details.scanned rather than a silent no-op.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
Idempotency-Key* | header | string | Required on this endpoint. A caller-chosen string, unique per logical request, used to make retries safe - see Idempotency in the API description. 1-255 chars |
Request body
| Field | Type | Description |
|---|---|---|
customerId* | string | 1-64 chars |
reversesRef* | string | The externalRef the receipt's transactions were recorded with. 1-64 chars |
note | string | Shown to the tenant on the resulting adjustment. Defaults to "Reversal of <reversesRef>". 1-300 chars |
Example
{
"customerId": "V1StGXR8_Z5j",
"reversesRef": "receipt-10246",
"note": "Storno uctenky c. 10246"
}Responses
| Status | Code | Description |
|---|---|---|
| 200 | - | Every transaction on this receipt had already been reversed; nothing changed. |
| 201 | - | Reversal recorded. |
| 400 | VALIDATION_ERROR | The request failed validation - a malformed body, an out-of-range or malformed query parameter, or a missing/malformed Idempotency-Key header on an endpoint that requires one. `details` holds the underlying validation issues when the failure is body/query shape. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 404 | NOT_FOUND | Unknown customer, or no reversible transaction with this `externalRef` within the scanned window. |
| 409 | - | This Idempotency-Key was already used with a different body (or is still in flight), or a rare optimistic-concurrency conflict that a straight retry resolves. |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
201 - Receipt earned 27 points, all of them taken back
{
"transaction": {
"id": "Zt4nWq8Bx2Cf",
"customerId": "V1StGXR8_Z5j",
"type": "adjust",
"amount": -27,
"spentCzk": null,
"rewardId": null,
"locationId": "Nk4pXz9Lm2Qr",
"actor": "api",
"externalRef": "receipt-10246",
"note": "Storno uctenky c. 10246",
"createdAt": "2026-08-12T09:41:02.000Z"
},
"customer": {
"id": "V1StGXR8_Z5j",
"name": "Jana Novakova",
"phone": "+420777123456",
"email": "jana.novakova@example.com",
"marketingOptIn": true,
"cardToken": "wlt_9pQ2xN4kTf7Lm1Rv",
"cardUrl": "https://app.wallento.cz/c/wlt_9pQ2xN4kTf7Lm1Rv",
"passState": "pending",
"visits": 14,
"firstTxAt": "2026-06-02T08:12:00.000Z",
"lastTxAt": "2026-08-12T09:41:02.000Z",
"originLocationId": "Nk4pXz9Lm2Qr",
"signupRef": "U008-1043",
"loyalty": {
"programType": "points",
"currentStamps": 0,
"pendingRewards": 0,
"pointsBalance": 156,
"lifetimeStamps": 0,
"lifetimePoints": 183,
"level": 0,
"levelName": "Bronz"
},
"createdAt": "2026-06-01T09:00:00.000Z",
"registeredAt": "2026-06-01T09:00:00.000Z"
},
"reversed": [
{
"id": "Qx8mLp2Kz9Rt",
"customerId": "V1StGXR8_Z5j",
"type": "points_earn",
"amount": 27,
"spentCzk": 270,
"rewardId": null,
"locationId": "Nk4pXz9Lm2Qr",
"actor": "api",
"externalRef": "receipt-10246",
"note": null,
"createdAt": "2026-08-12T09:12:44.000Z"
}
],
"requestedPoints": -27,
"appliedPoints": -27,
"clamped": false,
"alreadyReversed": false
}404 - Receipt not found in this customer's recent history
{
"error": {
"code": "NOT_FOUND",
"message": "No reversible transaction with this externalRef for this customer",
"details": {
"scanned": 200
}
}
}Example request
shell
curl -X POST https://app.wallento.cz/api/v1/transactions/reverse \
-H "Authorization: Bearer wlt_sk_..." \
-H "Idempotency-Key: a-unique-request-id" \
-H "Content-Type: application/json" \
-d '{"customerId":"V1StGXR8_Z5j","reversesRef":"receipt-10246","note":"Storno uctenky c. 10246"}'Program
The tenant's loyalty program configuration - stamps or points, rewards, levels.
/v1/programGet the loyalty program configuration
Scope: pos or program:read.
Responses
| Status | Code | Description |
|---|---|---|
| 200 | - | Program configuration. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 404 | NOT_FOUND | No resource with this id exists for the calling tenant. Also returned - deliberately indistinguishable from a genuinely missing id - when the id belongs to a DIFFERENT tenant: the API never confirms another tenant's data exists with a 403. |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
Example request
shell
curl https://app.wallento.cz/api/v1/program \
-H "Authorization: Bearer wlt_sk_..."/v1/programUpdate the loyalty program configuration
All fields optional; an unspecified field keeps its current value. Rewards are merged by id - omit id to mint a new reward, include an existing one to update it in place; sending rewards replaces the full array (any existing reward not included is dropped). levels, when sent, replaces the full array and thresholds must be strictly increasing. Scope: program:write.
Request body
| Field | Type | Description |
|---|---|---|
programType | "stamps" | "points" | |
stampsRequired | integer | 3-20 |
rewardText | string | 1-120 chars |
pointsPerCzk | number | 0.01-10 |
rewards | array of RewardInput | 0-200 items |
levels | array of LevelDef | Replaces the full levels array. Thresholds must be strictly increasing. 0-20 items |
Example
{
"stampsRequired": 10,
"rewardText": "Free coffee"
}Responses
| Status | Code | Description |
|---|---|---|
| 200 | - | Program updated. |
| 400 | VALIDATION_ERROR | The request failed validation - a malformed body, an out-of-range or malformed query parameter, or a missing/malformed Idempotency-Key header on an endpoint that requires one. `details` holds the underlying validation issues when the failure is body/query shape. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 404 | NOT_FOUND | No resource with this id exists for the calling tenant. Also returned - deliberately indistinguishable from a genuinely missing id - when the id belongs to a DIFFERENT tenant: the API never confirms another tenant's data exists with a 403. |
| 409 | PROGRAM_LOCKED | Attempted to change `programType` while the tenant already has customers. |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
409
{
"error": {
"code": "PROGRAM_LOCKED",
"message": "Cannot change program type when customers exist"
}
}Example request
shell
curl -X PATCH https://app.wallento.cz/api/v1/program \
-H "Authorization: Bearer wlt_sk_..." \
-H "Content-Type: application/json" \
-d '{"stampsRequired":10,"rewardText":"Free coffee"}'Locations
Physical locations (branches/tills) a tenant operates.
/v1/locationsList locations
Every location for the tenant, unpaginated. Scope: locations:read - deliberately not accepted with pos: the response includes each location's staff login code, and a location-bound key must stay unable to enumerate every other branch's login code.
Responses
| Status | Code | Description |
|---|---|---|
| 200 | - | All locations. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
Example request
shell
curl https://app.wallento.cz/api/v1/locations \
-H "Authorization: Bearer wlt_sk_..."/v1/locationsIdempotency-Key acceptedCreate a location
Scope: locations:write. Accepts Idempotency-Key but does not require it. A unique staff login code is minted server-side.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Optional on this endpoint. When present, a retried request with the same key and body replays the original response instead of repeating the side effect - see Idempotency in the API description. 1-255 chars |
Request body
| Field | Type | Description |
|---|---|---|
name* | string | 1-80 chars |
pin* | string | 4 to 6 digit PIN staff use with the location's login code at /scan/{code}. Never returned by the API. pattern ^\d{4,6}$ |
address* | string | Postal address. The server geocodes it via Google Geocoding API to derive `lat`/`lng`/`geoConfidence` in the response. Normalization (trimming whitespace) is server-side. If geocoding fails or `GOOGLE_GEOCODING_API_KEY` is unconfigured, the location is still created but without coordinates - the admin UI flags it for manual follow-up. 5-200 chars |
Example
{
"name": "Vinohrady",
"pin": "4821",
"address": "Korunni 15, 120 00 Praha 2"
}Responses
| Status | Code | Description |
|---|---|---|
| 201 | - | Location created. |
| 400 | VALIDATION_ERROR | The request failed validation - a malformed body, an out-of-range or malformed query parameter, or a missing/malformed Idempotency-Key header on an endpoint that requires one. `details` holds the underlying validation issues when the failure is body/query shape. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 409 | CONFLICTIDEMPOTENCY_CONFLICT | Either the generated location code collided after several attempts (retry), or this Idempotency-Key was already used with a different body (or is still in flight). |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
409 - Code collision, retry
{
"error": {
"code": "CONFLICT",
"message": "Location code already exists"
}
}409 - Idempotency-Key reused with a different body
{
"error": {
"code": "IDEMPOTENCY_CONFLICT",
"message": "Idempotency-Key was already used with a different request body, or the original request is still in flight"
}
}Example request
shell
curl -X POST https://app.wallento.cz/api/v1/locations \
-H "Authorization: Bearer wlt_sk_..." \
-H "Idempotency-Key: a-unique-request-id" \
-H "Content-Type: application/json" \
-d '{"name":"Vinohrady","pin":"4821","address":"Korunni 15, 120 00 Praha 2"}'/v1/locations/{lid}Update a location
At least one of name, address, or pin must be provided. Changing the PIN immediately revokes every active staff session for this location. Changing the address re-geocodes it server-side. Scope: locations:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
lid* | path | string | Location id. |
Request body
| Field | Type | Description |
|---|---|---|
name | string | 1-80 chars |
address | string | Postal address. When provided, the server re-geocodes it - even if it is byte-identical to the address already on file, as long as the location has no coordinates yet. If geocoding fails or `GOOGLE_GEOCODING_API_KEY` is unconfigured, the location is still updated but any previously derived lat/lng/geoConfidence are cleared (returned as null) rather than left pointing at the address being replaced. 5-200 chars |
pin | string | pattern ^\d{4,6}$ |
Example
{
"name": "Vinohrady - Korunni"
}Responses
| Status | Code | Description |
|---|---|---|
| 200 | - | Location updated. |
| 400 | VALIDATION_ERROR | The request failed validation - a malformed body, an out-of-range or malformed query parameter, or a missing/malformed Idempotency-Key header on an endpoint that requires one. `details` holds the underlying validation issues when the failure is body/query shape. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 404 | NOT_FOUND | No resource with this id exists for the calling tenant. Also returned - deliberately indistinguishable from a genuinely missing id - when the id belongs to a DIFFERENT tenant: the API never confirms another tenant's data exists with a 403. |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
Example request
shell
curl -X PATCH https://app.wallento.cz/api/v1/locations/Nk4pXz9Lm2Qr \
-H "Authorization: Bearer wlt_sk_..." \
-H "Content-Type: application/json" \
-d '{"name":"Vinohrady - Korunni"}'Reports
Aggregate monthly statistics.
/v1/reports/monthlyGet a tenant's monthly stats
Scope: reports:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
month | query | string | YYYY-MM. Defaults to the current month. pattern ^\d{4}-(0[1-9]|1[0-2])$ |
Responses
| Status | Code | Description |
|---|---|---|
| 200 | - | Stats for the requested month (zeros for a month with no recorded activity). |
| 400 | VALIDATION_ERROR | The request failed validation - a malformed body, an out-of-range or malformed query parameter, or a missing/malformed Idempotency-Key header on an endpoint that requires one. `details` holds the underlying validation issues when the failure is body/query shape. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
Example request
shell
curl https://app.wallento.cz/api/v1/reports/monthly \
-H "Authorization: Bearer wlt_sk_..."Broadcasts
Push a message to every customer with a synced wallet pass.
/v1/broadcastsIdempotency-Key acceptedSend a broadcast push
Pushes a message (with an automatic date prefix) to every customer with a synced wallet pass. Scope: broadcast:send. Hard limit: 2 per hour per tenant, enforced in addition to the general per-key write limit - either budget being exhausted returns 429 RATE_LIMITED. Accepts Idempotency-Key but does not require it; when provided, a rate-limited attempt does not consume it, so the same key can be retried once the tenant's budget has headroom again.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Optional on this endpoint. When present, a retried request with the same key and body replays the original response instead of repeating the side effect - see Idempotency in the API description. 1-255 chars |
Request body
| Field | Type | Description |
|---|---|---|
message* | string | Free text. A date prefix is added automatically. 1-180 chars |
Example
{
"message": "New seasonal menu just dropped! Come try it this week."
}Responses
| Status | Code | Description |
|---|---|---|
| 200 | - | Broadcast fanned out. `failed` counts individual delivery-queue failures - the request as a whole still succeeds. |
| 400 | VALIDATION_ERROR | The request failed validation - a malformed body, an out-of-range or malformed query parameter, or a missing/malformed Idempotency-Key header on an endpoint that requires one. `details` holds the underlying validation issues when the failure is body/query shape. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 409 | IDEMPOTENCY_CONFLICT | This Idempotency-Key was already used with a different request body, or the original request is still in flight. |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
409
{
"error": {
"code": "IDEMPOTENCY_CONFLICT",
"message": "Idempotency-Key was already used with a different request body, or the original request is still in flight"
}
}Example request
shell
curl -X POST https://app.wallento.cz/api/v1/broadcasts \
-H "Authorization: Bearer wlt_sk_..." \
-H "Idempotency-Key: a-unique-request-id" \
-H "Content-Type: application/json" \
-d '{"message":"New seasonal menu just dropped! Come try it this week."}'Webhooks
Register endpoints to receive domain events.
/v1/webhooksList webhook endpoints
Scope: webhooks:manage. secret is always redacted here (whsec_... plus the last 4 characters) - the full value is only ever returned once, by the create call below.
Responses
| Status | Code | Description |
|---|---|---|
| 200 | - | All webhook endpoints for the tenant. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
Example request
shell
curl https://app.wallento.cz/api/v1/webhooks \
-H "Authorization: Bearer wlt_sk_..."/v1/webhooksIdempotency-Key acceptedRegister a webhook endpoint
Scope: webhooks:manage. Accepts Idempotency-Key but does not require it - a retried registration must not silently mint a second endpoint, so pass one if your client may retry. url must use https in production and must not resolve to a loopback, link-local, or private-range host. ping is not a subscribable event; use POST /v1/webhooks/{id}/test to send one. A tenant may register at most 5 endpoints.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Optional on this endpoint. When present, a retried request with the same key and body replays the original response instead of repeating the side effect - see Idempotency in the API description. 1-255 chars |
Request body
| Field | Type | Description |
|---|---|---|
url* | string | Must use https in production; must not resolve to a loopback, link-local, or private-range host. up to 500 chars |
events* | array of SubscribableWebhookEventType | 1-unlimited items |
Example
{
"url": "https://example.com/webhooks/wallento",
"events": [
"transaction.created",
"customer.created"
]
}Responses
| Status | Code | Description |
|---|---|---|
| 201 | - | Webhook registered. This is the ONLY response that ever includes the full `secret` - store it now, it cannot be retrieved again. |
| 400 | VALIDATION_ERROR | The request failed validation - a malformed body, an out-of-range or malformed query parameter, or a missing/malformed Idempotency-Key header on an endpoint that requires one. `details` holds the underlying validation issues when the failure is body/query shape. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 409 | WEBHOOK_LIMITIDEMPOTENCY_CONFLICT | Either the tenant is already at the 5-endpoint limit, or this Idempotency-Key was already used with a different request body (or is still in flight). |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
201 - Full secret, shown once
{
"webhook": {
"id": "wh_3n7k2q9p1x8m",
"url": "https://example.com/webhooks/wallento",
"events": [
"transaction.created",
"customer.created"
],
"secret": "whsec_k3n9p2q7x1m8v4t6r5w0y2z3a1b2c3d4",
"createdAt": "2026-08-09T14:30:00.000Z"
}
}409 - Endpoint limit reached
{
"error": {
"code": "WEBHOOK_LIMIT",
"message": "A tenant may register at most 5 webhook endpoints"
}
}409 - Idempotency-Key reused with a different body
{
"error": {
"code": "IDEMPOTENCY_CONFLICT",
"message": "Idempotency-Key was already used with a different request body, or the original request is still in flight"
}
}Example request
shell
curl -X POST https://app.wallento.cz/api/v1/webhooks \
-H "Authorization: Bearer wlt_sk_..." \
-H "Idempotency-Key: a-unique-request-id" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/webhooks/wallento","events":["transaction.created","customer.created"]}'/v1/webhooks/{whid}Delete a webhook endpoint
Scope: webhooks:manage. Irreversible.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
whid* | path | string | Webhook endpoint id. |
Responses
| Status | Code | Description |
|---|---|---|
| 200 | - | Webhook deleted. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 404 | NOT_FOUND | No resource with this id exists for the calling tenant. Also returned - deliberately indistinguishable from a genuinely missing id - when the id belongs to a DIFFERENT tenant: the API never confirms another tenant's data exists with a 403. |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
Example request
shell
curl -X DELETE https://app.wallento.cz/api/v1/webhooks/wh_3n7k2q9p1x8m \
-H "Authorization: Bearer wlt_sk_..."/v1/webhooks/{whid}/testIdempotency-Key acceptedSend a test ping event
Enqueues a single ping event through the exact same signed-delivery path as a real domain event, so you can verify an endpoint and its signature check before relying on it. Scope: webhooks:manage. Accepts Idempotency-Key but does not require it - without one, a retried request after a lost response enqueues a second ping.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
whid* | path | string | Webhook endpoint id. |
Idempotency-Key | header | string | Optional on this endpoint. When present, a retried request with the same key and body replays the original response instead of repeating the side effect - see Idempotency in the API description. 1-255 chars |
Responses
| Status | Code | Description |
|---|---|---|
| 200 | - | The ping was accepted for delivery. `enqueued: false` means the delivery queue itself rejected the job (rare, an infrastructure issue on our side, not a signal about your endpoint) - the event was not attempted. |
| 400 | VALIDATION_ERROR | The request failed validation - a malformed body, an out-of-range or malformed query parameter, or a missing/malformed Idempotency-Key header on an endpoint that requires one. `details` holds the underlying validation issues when the failure is body/query shape. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 404 | NOT_FOUND | No resource with this id exists for the calling tenant. Also returned - deliberately indistinguishable from a genuinely missing id - when the id belongs to a DIFFERENT tenant: the API never confirms another tenant's data exists with a 403. |
| 409 | IDEMPOTENCY_CONFLICT | This Idempotency-Key was already used with a different request body, or the original request is still in flight. |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
409
{
"error": {
"code": "IDEMPOTENCY_CONFLICT",
"message": "Idempotency-Key was already used with a different request body, or the original request is still in flight"
}
}Example request
shell
curl -X POST https://app.wallento.cz/api/v1/webhooks/wh_3n7k2q9p1x8m/test \
-H "Authorization: Bearer wlt_sk_..." \
-H "Idempotency-Key: a-unique-request-id"API keys
Provisioning of register tokens - the location-bound `pos` keys a till authenticates with. Only ever register tokens: this surface can neither mint nor revoke a key with admin-shaped scopes.
/v1/apikeysList register tokens
Scope: apikeys:manage. Lists only location-bound pos keys, revoked ones included (audit trail) - the tenant's admin-shaped keys are never visible here. Secrets are never returned; match a key you already hold by its last6.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
locationId | query | string | Return only the tokens bound to this location. |
Responses
| Status | Code | Description |
|---|---|---|
| 200 | - | Register tokens, newest first. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
Example request
shell
curl https://app.wallento.cz/api/v1/apikeys \
-H "Authorization: Bearer wlt_sk_..."/v1/apikeysMint a register token for a location
Scope: apikeys:manage. Mints exactly one pos key bound to locationId. You cannot choose scopes and you cannot mint a tenant-wide key here - the shape is fixed so a leaked provisioning key can never escalate itself into an admin-shaped one.
Idempotency-Key is REFUSED on this endpoint (400), unlike every other v1 POST: replay works by storing the original response body, and this one carries the key plaintext - the single value Wallento never persists. Repeat safety comes instead from the one-live-token-per-location rule: a second POST for the same location answers 409 KEY_EXISTS until you pass "rotate": true.
Request body
| Field | Type | Description |
|---|---|---|
name* | string | Human label shown in the tenant admin's key list. up to 60 chars |
locationId* | string | The location this till stands in. Must belong to your tenant. up to 64 chars |
rotate | boolean | false: a location that already has a live register token answers 409 `KEY_EXISTS`. true: revoke every live token bound to that location first, then mint a fresh one - the old tokens stop working immediately. default false |
Example
{
"name": "Tillo - Praha 1",
"locationId": "Xsf5V5hNMhiy"
}Responses
| Status | Code | Description |
|---|---|---|
| 201 | - | Token minted. This is the ONLY response that ever contains the secret - store it now, it cannot be retrieved again. |
| 400 | VALIDATION_ERROR | The request failed validation - a malformed body, an out-of-range or malformed query parameter, or a missing/malformed Idempotency-Key header on an endpoint that requires one. `details` holds the underlying validation issues when the failure is body/query shape. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 404 | NOT_FOUND | No resource with this id exists for the calling tenant. Also returned - deliberately indistinguishable from a genuinely missing id - when the id belongs to a DIFFERENT tenant: the API never confirms another tenant's data exists with a 403. |
| 409 | KEY_EXISTS | `KEY_EXISTS` - the location already has a live token (repeat with `"rotate": true` to replace it); or `LIMIT_REACHED` - the tenant is at its live-key ceiling. |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
409
{
"error": {
"code": "KEY_EXISTS",
"message": "This location already has a live register token. Send \"rotate\": true to replace it."
}
}Example request
shell
curl -X POST https://app.wallento.cz/api/v1/apikeys \
-H "Authorization: Bearer wlt_sk_..." \
-H "Content-Type: application/json" \
-d '{"name":"Tillo - Praha 1","locationId":"Xsf5V5hNMhiy"}'/v1/apikeys/{akid}Revoke a register token
Scope: apikeys:manage. Takes effect immediately and is irreversible; the record stays listed for audit with revokedAt set. Only location-bound tokens can be revoked here - an id belonging to an admin-shaped key answers 404, so a leaked provisioning key cannot switch off your other integrations. Repeating the call on an already-revoked token is a safe no-op.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
akid* | path | string | Register token id (`ak_...`), as returned when the key was minted. |
Responses
| Status | Code | Description |
|---|---|---|
| 200 | - | Token revoked. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 404 | NOT_FOUND | No resource with this id exists for the calling tenant. Also returned - deliberately indistinguishable from a genuinely missing id - when the id belongs to a DIFFERENT tenant: the API never confirms another tenant's data exists with a 403. |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
Example request
shell
curl -X DELETE https://app.wallento.cz/api/v1/apikeys/akid \
-H "Authorization: Bearer wlt_sk_..."Users
Provisioning and role management for the /t-facing admin accounts (owner, franchisee, manager) and staff profiles a partner backend manages, plus one-time login links. Scope: `users:manage` - never available to a location-bound `pos` key.
/v1/usersList users
Scope: users:manage. Cursor-paginated (see Pagination in the API description). externalRef and email are mutually exclusive filters, each returning a 0/1-item page with no nextCursor - same shape as the customer lookups (GET /v1/customers?phone=/?email=). email matches against the GLOBAL email index (an email belongs to exactly one User across every tenant); a match belonging to a different tenant is indistinguishable from no match.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
limit | query | integer | 1-200 - default 50 |
cursor | query | string | Opaque pagination cursor taken from a previous response's `nextCursor`. A malformed value returns 400 VALIDATION_ERROR. |
externalRef | query | string | Return only the user with this externalRef, if any. |
email | query | string | Return only the user with this email, if any (and it belongs to your tenant). |
Responses
| Status | Code | Description |
|---|---|---|
| 200 | - | Users, newest first. |
| 400 | VALIDATION_ERROR | The request failed validation - a malformed body, an out-of-range or malformed query parameter, or a missing/malformed Idempotency-Key header on an endpoint that requires one. `details` holds the underlying validation issues when the failure is body/query shape. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
Example request
shell
curl https://app.wallento.cz/api/v1/users \
-H "Authorization: Bearer wlt_sk_..."/v1/usersIdempotency-Key requiredProvision a user (create, or upsert by externalRef)
Scope: users:manage. Requires Idempotency-Key (see Idempotency in the API description). When externalRef is given and already matches an existing user of your tenant, this behaves like PATCH /v1/users/{uid} instead of creating a second record (200, not 201) - the natural key a re-run provisioning sync upserts on, independent of the Idempotency-Key mechanism (which only protects a single retried request, not a daily re-sync that mints a fresh key every time). email must not already belong to a user of a DIFFERENT tenant (409 EMAIL_TAKEN); every locationId must belong to your tenant (422 VALIDATION_ERROR).
Parameters
| Name | In | Type | Description |
|---|---|---|---|
Idempotency-Key* | header | string | Required on this endpoint. A caller-chosen string, unique per logical request, used to make retries safe - see Idempotency in the API description. 1-255 chars |
Request body
| Field | Type | Description |
|---|---|---|
email* | string | up to 200 chars |
name* | string | 1-100 chars |
role* | UserRole | |
locationIds | array of string | Omit or send [] for every location of the tenant. 0-500 items |
externalRef | string | Your own id for this user. When it already matches an existing user of your tenant, this call upserts that user instead of creating a second one. 1-128 chars |
Example
{
"email": "jana@example.com",
"name": "Jana Novakova",
"role": "manager",
"locationIds": [
"Xsf5V5hNMhiy"
],
"externalRef": "employee-482"
}Responses
| Status | Code | Description |
|---|---|---|
| 200 | - | Upserted an existing user matched by externalRef. |
| 201 | - | A new user was created. |
| 400 | VALIDATION_ERROR | The request failed validation - a malformed body, an out-of-range or malformed query parameter, or a missing/malformed Idempotency-Key header on an endpoint that requires one. `details` holds the underlying validation issues when the failure is body/query shape. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 409 | EMAIL_TAKENLAST_OWNERIDEMPOTENCY_CONFLICT | `EMAIL_TAKEN` - the email belongs to a user of a different tenant; `LAST_OWNER` - an externalRef upsert would re-role or otherwise remove the tenant's last active owner; `REF_TAKEN` - a rare concurrent-create race on a brand-new externalRef; or this Idempotency-Key was already used with a different body (or is still in flight). |
| 422 | VALIDATION_ERROR | One or more `locationIds` do not belong to your tenant. |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
409 - Email belongs to another tenant's user
{
"error": {
"code": "EMAIL_TAKEN",
"message": "A user with this email already exists"
}
}409 - Would remove the tenant's last active owner
{
"error": {
"code": "LAST_OWNER",
"message": "This is the tenant's last active owner and cannot lose owner access through this call"
}
}409 - Idempotency-Key reused with a different body
{
"error": {
"code": "IDEMPOTENCY_CONFLICT",
"message": "Idempotency-Key was already used with a different request body, or the original request is still in flight"
}
}422
{
"error": {
"code": "VALIDATION_ERROR",
"message": "One or more locationIds do not belong to this tenant"
}
}Example request
shell
curl -X POST https://app.wallento.cz/api/v1/users \
-H "Authorization: Bearer wlt_sk_..." \
-H "Idempotency-Key: a-unique-request-id" \
-H "Content-Type: application/json" \
-d '{"email":"jana@example.com","name":"Jana Novakova","role":"manager","locationIds":["Xsf5V5hNMhiy"],"externalRef":"employee-482"}'/v1/users/{uid}Get a user
Scope: users:manage.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
uid* | path | string | User id. |
Responses
| Status | Code | Description |
|---|---|---|
| 200 | - | The user. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 404 | NOT_FOUND | No resource with this id exists for the calling tenant. Also returned - deliberately indistinguishable from a genuinely missing id - when the id belongs to a DIFFERENT tenant: the API never confirms another tenant's data exists with a 403. |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
Example request
shell
curl https://app.wallento.cz/api/v1/users/uid \
-H "Authorization: Bearer wlt_sk_..."/v1/users/{uid}Update a user
Scope: users:manage. At least one of email, name, role, locationIds, status or externalRef must be provided. externalRef is normally set once at creation (POST /v1/users) and used as the provisioning natural key - this endpoint only accepts it to ADOPT a user that doesn't have one yet (created by hand, or via POST without externalRef); once set, sending a different value is rejected (409 REF_IMMUTABLE). Setting status: "disabled" here has the same effect as DELETE /v1/users/{uid} (soft-disable; a live session for this user is cut off on its next revalidation). email must not already belong to a user of a different tenant (409 EMAIL_TAKEN); a patch that would leave the tenant with no active owner is rejected (409 LAST_OWNER); every locationId must belong to your tenant (422 VALIDATION_ERROR).
Parameters
| Name | In | Type | Description |
|---|---|---|---|
uid* | path | string | User id. |
Request body
| Field | Type | Description |
|---|---|---|
email | string | up to 200 chars |
name | string | 1-100 chars |
role | UserRole | |
locationIds | array of string | 0-500 items |
status | "active" | "disabled" | Setting disabled has the same effect as DELETE /v1/users/{uid}. |
externalRef | string | Attaches this ref to a user that doesn't have one yet (adoption). Rejected with 409 REF_IMMUTABLE if the user already has a different ref, and with 409 REF_TAKEN if another user of your tenant already has this one. 1-128 chars |
Example
{
"locationIds": [
"Xsf5V5hNMhiy"
]
}Responses
| Status | Code | Description |
|---|---|---|
| 200 | - | User updated. |
| 400 | VALIDATION_ERROR | The request failed validation - a malformed body, an out-of-range or malformed query parameter, or a missing/malformed Idempotency-Key header on an endpoint that requires one. `details` holds the underlying validation issues when the failure is body/query shape. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 404 | NOT_FOUND | No resource with this id exists for the calling tenant. Also returned - deliberately indistinguishable from a genuinely missing id - when the id belongs to a DIFFERENT tenant: the API never confirms another tenant's data exists with a 403. |
| 409 | EMAIL_TAKENLAST_OWNERREF_IMMUTABLEREF_TAKEN | `EMAIL_TAKEN` - the email belongs to a user of a different tenant; `LAST_OWNER` - this patch would leave the tenant with no active owner; `REF_IMMUTABLE` - the user already has a different externalRef; or `REF_TAKEN` - the given externalRef already belongs to another user of your tenant. |
| 422 | VALIDATION_ERROR | One or more `locationIds` do not belong to your tenant. |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
409 - Email belongs to another tenant's user
{
"error": {
"code": "EMAIL_TAKEN",
"message": "A user with this email already exists"
}
}409 - Would leave the tenant with no active owner
{
"error": {
"code": "LAST_OWNER",
"message": "This is the tenant's last active owner and cannot lose owner access through this call"
}
}409 - User already has a different externalRef
{
"error": {
"code": "REF_IMMUTABLE",
"message": "externalRef is already set for this user and cannot be reassigned through this call"
}
}409 - externalRef already used by another user of this tenant
{
"error": {
"code": "REF_TAKEN",
"message": "externalRef already used by another user of this tenant"
}
}422
{
"error": {
"code": "VALIDATION_ERROR",
"message": "One or more locationIds do not belong to this tenant"
}
}Example request
shell
curl -X PATCH https://app.wallento.cz/api/v1/users/uid \
-H "Authorization: Bearer wlt_sk_..." \
-H "Content-Type: application/json" \
-d '{"locationIds":["Xsf5V5hNMhiy"]}'/v1/users/{uid}Disable a user
Scope: users:manage. Soft delete: sets status to disabled - never removes the record, and it stays visible in GET /v1/users/GET /v1/users/{uid}. A live session for this user is cut off on its next revalidation. The tenant's last active owner cannot be disabled this way (409 LAST_OWNER), so a partner integration can never lock a tenant out of its own admin.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
uid* | path | string | User id. |
Responses
| Status | Code | Description |
|---|---|---|
| 200 | - | User disabled. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 404 | NOT_FOUND | No resource with this id exists for the calling tenant. Also returned - deliberately indistinguishable from a genuinely missing id - when the id belongs to a DIFFERENT tenant: the API never confirms another tenant's data exists with a 403. |
| 409 | LAST_OWNER | `LAST_OWNER` - this is the tenant's last active owner. |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
409
{
"error": {
"code": "LAST_OWNER",
"message": "This is the tenant's last active owner and cannot be deactivated through this call"
}
}Example request
shell
curl -X DELETE https://app.wallento.cz/api/v1/users/uid \
-H "Authorization: Bearer wlt_sk_..."/v1/users/{uid}/login-linkMint a one-time login link
Scope: users:manage. Mints a single-use URL that signs the target user directly into /t, no password or email round-trip required - meant for a partner backend's own "log in as this employee" action. The token expires after 60 seconds and is consumed on first use (a second visit to the same URL fails and redirects to the sign-in page). Rejected for a staff user (422 STAFF_NO_LOGIN - staff never signs in to /t) and for a disabled user (422 USER_DISABLED). Tenant-scoped budget: 60 mints/minute, in addition to the general per-key write limit - see Rate limits in the API description. Does not accept Idempotency-Key - there is no request body, and every call is meant to mint a fresh token, never replay a previous one. The URL itself is a bearer credential for the 60 seconds it lives: never log it, and hand it only to the already-authenticated operator it was minted for.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
uid* | path | string | User id. |
Responses
| Status | Code | Description |
|---|---|---|
| 200 | - | Login link minted. |
| 401 | UNAUTHORIZED | The bearer token is missing, malformed, or does not match a live (non-revoked) API key. Carries no X-RateLimit-* headers - no key has been resolved yet. |
| 403 | FORBIDDEN_SCOPETENANT_ARCHIVED | The key is valid but either lacks every scope this operation requires, or belongs to a tenant that has been archived. Carries no X-RateLimit-* headers - both checks happen before a per-key limiter is consulted. |
| 404 | NOT_FOUND | No resource with this id exists for the calling tenant. Also returned - deliberately indistinguishable from a genuinely missing id - when the id belongs to a DIFFERENT tenant: the API never confirms another tenant's data exists with a 403. |
| 422 | STAFF_NO_LOGINUSER_DISABLED | `STAFF_NO_LOGIN` - staff users never sign in to `/t`; or `USER_DISABLED` - this user is disabled. |
| 429 | RATE_LIMITED | Too many requests. Back off and retry after `Retry-After` seconds. See Rate limits in the API description for which budget applies to this endpoint. |
422 - Staff never signs in to /t
{
"error": {
"code": "STAFF_NO_LOGIN",
"message": "Staff users cannot receive a login link - staff never signs in to /t"
}
}422 - User is disabled
{
"error": {
"code": "USER_DISABLED",
"message": "This user is disabled"
}
}Example request
shell
curl -X POST https://app.wallento.cz/api/v1/users/uid/login-link \
-H "Authorization: Bearer wlt_sk_..."Schemas
Shared object shapes referenced from the tables above.
Customer
| Field | Type | Description |
|---|---|---|
id* | string | |
name* | string | |
phone* | string | E.164 format, for example +420777123456. |
email* | string | Empty string when the customer has no email on file. |
marketingOptIn* | boolean | |
cardToken* | string | Opaque token identifying the customer's wallet card. |
cardUrl* | string | Public URL of the customer's wallet card (Add to Apple/Google Wallet). |
passState* | "ok" | "pending" | "error" | Wallet pass sync state: pending briefly after creation or a state change, error if the wallet provider rejected the last sync. |
visits* | integer | Count of stamp/points_earn transactions - a redeem or an admin adjust does not count as a new visit. 0 for a customer with none yet. at least 0 |
firstTxAt* | string or null | Timestamp of this customer's first transaction of any kind, null if none yet. |
lastTxAt* | string or null | Timestamp of this customer's most recent transaction of any kind, null if none yet. |
originLocationId* | string or null | The location whose join QR issued this card, captured once at signup - provenance, not ownership. Null when the card was issued through the tenant-wide print page's QR, or predates this field. |
signupRef* | string or null | Receipt attribution code the till printed on the receipt whose QR led to this signup, shape "KODPRODEJNY-CISLOUCTENKY" (e.g. "U008-1043"), captured once at signup and never rewritten afterwards. Null when the signup came from a QR carrying no ref (tenant-wide print page, a location's own QR without a receipt behind it), or predates this field. |
loyalty* | Loyalty | |
createdAt* | string | |
registeredAt* | string | Same instant as createdAt - a stable alias partner integrations can rely on independent of internal naming. |
Loyalty
| Field | Type | Description |
|---|---|---|
programType* | "stamps" | "points" | |
currentStamps* | integer | Stamps toward the customer's next reward, on a stamps program. at least 0 |
pendingRewards* | integer | Earned-but-not-yet-redeemed stamp-card rewards. at least 0 |
pointsBalance* | integer | Current spendable points balance, on a points program. at least 0 |
lifetimeStamps* | integer | at least 0 |
lifetimePoints* | integer | at least 0 |
level* | integer | Index into the program's `levels` array. -1 when the customer has not reached any level yet - whether because the tenant has no levels configured, or because lifetime progress is still below the first threshold. |
levelName* | string or null | Name of the customer's current level, or null when `level` is -1 (no level reached yet). |
Transaction
| Field | Type | Description |
|---|---|---|
id* | string | |
customerId* | string | |
type* | "stamp" | "points_earn" | "redeem" | "adjust" | The stored transaction kind - distinct from the action names POST /v1/transactions accepts (stamp, spend, redeem_stamp, redeem_points): spend produces points_earn; both redeem_stamp and redeem_points produce redeem; adjust is admin-only and never written by this API. |
amount* | integer | Signed magnitude of the change: +1 for a stamp, +points for points_earn, -1 or -points for redeem, positive or negative for an admin adjust. |
spentCzk* | integer or null | Amount spent in CZK, present only on a points_earn transaction created from a spend action. |
rewardId* | string or null | The redeemed reward's id, present only on a points-reward redemption. |
locationId* | string or null | |
actor* | "staff" | "admin" | "system" | "api" | Who made this write: the staff scanner app, the tenant admin, an internal system process, or this partner API. |
externalRef* | string or null | Partner-supplied receipt/order id, echoed back verbatim when this transaction was created via this API with externalRef set. |
note* | string or null | |
createdAt* | string |
Program
| Field | Type | Description |
|---|---|---|
programType* | "stamps" | "points" | |
stamps* | StampsConfig or null | |
points* | PointsConfig or null | |
levels* | array of LevelDef |
StampsConfig
| Field | Type | Description |
|---|---|---|
stampsRequired* | integer | 3-20 |
rewardText* | string |
PointsConfig
| Field | Type | Description |
|---|---|---|
pointsPerCzk* | number | 0.01-10 |
rewards* | array of Reward |
Reward
| Field | Type | Description |
|---|---|---|
id* | string | |
name* | string | |
costPoints* | integer | at least 1 |
RewardInput
| Field | Type | Description |
|---|---|---|
id | string | Omit to mint a new reward; include an existing reward's id to update it in place. up to 40 chars |
name* | string | 1-60 chars |
costPoints* | integer | 1-1000000 |
LevelDef
| Field | Type | Description |
|---|---|---|
name* | string | 1-40 chars |
threshold* | integer | Lifetime stamps or points required to reach this level. 0-10000000 |
color | string | pattern ^#[0-9a-fA-F]{6}$ |
benefitText | string | up to 200 chars |
Location
| Field | Type | Description |
|---|---|---|
id* | string | |
name* | string | |
code* | string | Staff login code used together with the location's PIN at /scan/{code}. Sensitive - anyone holding both can sign in as staff for this location. |
createdAt* | string | |
address* | string or null | Postal address of this location, geocoded server-side to `lat`/`lng` for Apple Wallet geofence triggers (Etapa 1). The API normalizes and returns the formatted address Google Geocoding provided. null for a location created before this field existed. |
lat* | number or null | Latitude coordinate derived from `address` by Google Geocoding API. null if address was not geocodable, `GOOGLE_GEOCODING_API_KEY` is not configured, or the location predates this field. |
lng* | number or null | Longitude coordinate derived from `address`. null if address was not geocodable, `GOOGLE_GEOCODING_API_KEY` is not configured, or the location predates this field. |
geoConfidence* | "exact" | "approximate" | null | 'exact' when Google's geocoder matched a rooftop-precision address (geometry.location_type === ROOFTOP), 'approximate' for street/city centroid matches, null alongside lat/lng when neither is set. Guides how carefully an admin should review the coordinate. |
Webhook
| Field | Type | Description |
|---|---|---|
id* | string | |
url* | string | |
events* | array of SubscribableWebhookEventType | |
secret* | string | whsec_ prefixed signing secret. The full value is present only in the response to POST /v1/webhooks, shown exactly once. Every other response redacts it to whsec_... followed by the last 4 characters. |
createdAt* | string |
WebhookEvent
| Field | Type | Description |
|---|---|---|
id* | string | evt_ prefixed event id, unique per delivery attempt's underlying event. |
type* | WebhookEventType | |
createdAt* | string | |
data* | object | Event-specific payload. customer.created/updated: { customer: Customer }. customer.deleted: { customerId }. transaction.created: { transaction: Transaction, customer: Customer, rewardEarned: boolean, levelUp: LevelDef | null }. broadcast.sent: { message: string, queued: integer, failed: integer }. ping: {} (empty). |
SubscribableWebhookEventType
Event types a webhook endpoint can subscribe to. ping is not in this list - it is only ever sent by POST /v1/webhooks/{id}/test.
"customer.created""customer.updated""customer.deleted""transaction.created""broadcast.sent"WebhookEventType
Every event type a delivery's type field can carry, including the test-only ping.
"customer.created""customer.updated""customer.deleted""transaction.created""broadcast.sent""ping"MonthlyStats
| Field | Type | Description |
|---|---|---|
visits* | integer | at least 0 |
newCustomers* | integer | at least 0 |
redemptions* | integer | at least 0 |
pointsIssued* | integer | at least 0 |
Error
| Field | Type | Description |
|---|---|---|
error* | object |