GET/v1/customers/{id}/loyalty
Get a guest's points and how they got them
Returns a guest's points balance and their recent movements, newest first.
The merchant-facing read. GET /v1/customer-loyalty/{token} is the guest's own, reached
from a receipt link and deliberately unauthenticated; this one is the person on the phone
with a guest, and an integration that needs to know what a guest has before offering them
something.
The balance is the sum of the movements, not a figure stored beside them, which is what makes it contestable: a guest who says they had more points can be shown where each one came from and went. Each movement's points are signed and its type says only why.
A guest of another merchant is not found, whichever merchant's ledger the points sit in — a franchise programme's ledger belongs to the franchisor, and the guest to the site.
Parameters
-
idpath · string · uuidRequired
UUIDv7 of the resource.
For resources a device can originate — orders, and their line items — this id is minted by the client before the record is ever synced, which is what makes an offline create idempotent.
Responses
-
200
The guest's balance and statement.
LoyaltyAccount · application/json
balance_pointsinteger · int64Required
Points available to spend now: the sum of every movement, so an expired earn is already taken off by the
expirethat wrote it off rather than filtered out here.customer_idstring · uuidRequired
-
statementarray of LoyaltyMovementRequired
The guest's movements, newest first, bounded to the most recent 50.
idstring · uuidRequired
typestring · enumRequired
Why the balance moved, never which way.
earna sale earned points;redeempoints paid for something;adjusta person credited or debited them by hand, with a note saying why;expirewhat was left of one earn was written off when its term ran out;reversea sale that earned or spent points was voided, so the movement came back.earnredeemadjustexpirereversecreated_atTimestampRequiredRead only
Server-assigned RFC 3339 timestamp, always UTC.
Server-assigned because device clocks drift and cannot be trusted for ordering — a till that has been powered off for a week may come back with a clock hours out. Where the device's own view of time matters it is carried separately in
client_created_at.expires_atTimestampNullable
When this earn stops being worth anything, set at earn time from the programme's term. Null for a redemption, a reversal, and for an earn under a programme that never expires points.
location_idstring · uuidNullable
The site the movement's sale was rung at, so a liability can be attributed to the site that issued it. Null for a movement with no sale.
notestringWhy a person made this adjustment. Empty for a movement from a sale.
order_idstring · uuidNullable
The sale this movement was part of, or null for one made by hand or by the expiry sweep.
pointsinteger · int64Required
Signed. Negative for points spent or written off, positive for points added.
-
400
The request could not be parsed. Malformed JSON, a bad query parameter, or a header that failed to decode, among them an
API-Versionlater than the server speaks or not a date (api-version-unknown, with the server's own inapi_version).Distinct from
422: a400never reached validation, soinvalid_paramsis absent. Retrying without changing the request will fail again, except that a request refused for itsAPI-Versiongoes once the server is updated. -
401
No credentials, or credentials that could not be verified — missing bearer token, malformed JWT, expired token, or a signature that failed against the issuer's JWKS.
Refresh the token and retry once. Repeated
401after a refresh means the device needs to re-enrol. -
403
Authenticated, but not permitted. The token is valid and its merchant was resolved, but the caller's role does not allow this operation, or the target belongs to a different merchant.
Cross-tenant access is reported as
403on a resource the caller may not see, and as404where even confirming existence would leak information. Retrying will not help. -
404
No such resource, or none visible to this caller.
Also returned instead of
403where acknowledging existence would itself leak information — so a404does not prove the id is unused globally, only that it is not yours. -
410
The request names an
API-Versionthis server no longer serves (docs/decisions/0011). Nothing in it was read.api_versionnames the version the server speaks: the client is behind, and sends the request again once it is updated. -
429
Rate limit exceeded. Honour
Retry-Afterrather than applying your own backoff.A fleet of tills coming back online together after an outage is the realistic cause, which is exactly when uncoordinated client-side retry turns a brief outage into a sustained one. The limit is per process, and per site for what records trade there — a request spends its site's budget, and a till's own smaller one inside it — and per
(client, merchant)for everything else; noRateLimitfield is sent, so this response and itsRetry-Afterare the whole of the contract. -
500
Something failed on our side.
trace_ididentifies the request in our tracing — quote it in a support ticket.The body never carries internal detail: no SQL, no stack traces, no upstream error strings. Safe to retry with the same
Idempotency-Key. -
503
Temporarily unable to serve the request — a dependency is down or the service is shedding load.
Retry with the same
Idempotency-KeyafterRetry-After. This is always safe: a request that did take effect before the failure will be recognised as a replay.