Loyalty Points API Example¶
A copy-paste walkthrough of the loyalty H2H endpoints: look up a customer, read their balance, grant points after a purchase, redeem points at checkout, and confirm the balance change. Hand this to vendors integrating loyalty with their POS flow.
All endpoints authenticate with a partner API key (X-Api-Key).
The proxy resolves the tenant from your subdomain and forwards the call
to the loyalty backend.
Setup¶
Replace the placeholders with values for your tenant and partner.
export BASE="https://{tenant}.sgp1.samba-technologies.xyz/service/pos-integration"
export KEY="<your-partner-api-key>"
CUST (a customerUuid) is produced by step 1 — you don't need to ask
Cata for one ahead of time.
Ask Cata for these values
Both are provisioned by the Cata team — they are not self-serve.
Contact your Cata account manager (or email support+api@cata.sg)
to request:
| Variable | What it is | How to request |
|---|---|---|
BASE |
Your tenant's base URL. The {tenant} portion is your tenant subdomain (e.g. acme → acme.sgp1.samba-technologies.xyz). |
Confirm your tenant subdomain with Cata. |
KEY |
Your partner API key. Long-lived; rotate by asking Cata for a new one. | Request per environment (dev / staging / prod). |
For local development, use http://localhost:8080 as $BASE.
Before running the flow, verify your key and tenant routing:
curl -sS "$BASE/api/v1/auth/whoami" -H "X-Api-Key: $KEY" | jq .
A 200 response with "authenticated": true confirms the key is valid
for the resolved tenant. If you get 401, the key is missing, invalid,
or expired — re-auth before continuing.
1. Look up a customer's customerUuid¶
Every other loyalty call is keyed by customerUuid. If your POS only
knows the customer's email — or you've scanned a QR code that resolves
to a short code — use this endpoint to discover the UUID.
The identifier parameter accepts any of these forms:
- An email (anything containing
@) - A phone number in international format (e.g.
+6281287736665) - A 6-character hex
short_code(e.g.A1B2C3, case-insensitive)
You do not need to branch client-side on which one you have — the loyalty backend classifies the identifier server-side. For a QR-scan flow, forward the scanned value verbatim.
# By email
curl -sS "$BASE/api/v1/loyalty/customer-uuid?identifier=customer@example.com" \
-H "X-Api-Key: $KEY" | jq .
# By phone number (URL-encode the leading "+" as %2B)
curl -sS "$BASE/api/v1/loyalty/customer-uuid?identifier=%2B6281287736665" \
-H "X-Api-Key: $KEY" | jq .
# By short code (case-insensitive)
curl -sS "$BASE/api/v1/loyalty/customer-uuid?identifier=A1B2C3" \
-H "X-Api-Key: $KEY" | jq .
{
"code": 0,
"isSuccess": true,
"message": "OK",
"customerUuid": "edf2f0ce-02da-11ef-8a42-42010a0a2005"
}
Save the returned customerUuid and use it for the rest of the flow:
export CUST="edf2f0ce-02da-11ef-8a42-42010a0a2005"
Business outcomes (HTTP 200, check isSuccess)¶
code |
isSuccess |
When |
|---|---|---|
0 |
true |
Found — customerUuid populated. |
1012 |
false |
identifier is missing, or is not a recognized form (email, phone number, or 6-hex short_code). |
1013 |
false |
No customer matches the identifier; customerUuid is "". |
// 1012 — malformed identifier
{ "code": 1012, "isSuccess": false, "message": "identifier must be an email, phone number, or 6-character hex short_code" }
// 1013 — unknown customer
{ "code": 1013, "isSuccess": false, "message": "Customer not found", "customerUuid": "" }
Both 1012 and 1013 come back with HTTP 200 — the failure signal
is in the body, same convention as the rest of the loyalty endpoints.
Cache the customerUuid on your side
A customer's customerUuid does not change. Once you've resolved an
email, phone, or short_code to a UUID, store the mapping in your own POS /
app and reuse it on every subsequent loyalty call — don't re-hit
/customer-uuid on each transaction. Refresh only if a lookup
starts returning code: 1013 (customer was removed) or if your
cache is invalidated by a profile change you control.
2. Check current balance¶
curl -sS "$BASE/api/v1/loyalty/balance?customerUuid=$CUST" \
-H "X-Api-Key: $KEY" | jq .
{
"code": 0,
"isSuccess": true,
"message": "OK",
"balance": 130,
"numOfPointsWillExpire": 10,
"nearestExpiryDateTimeUTC": "2026-12-31T15:59:59Z"
}
| Field | Meaning |
|---|---|
balance |
Current usable points. |
numOfPointsWillExpire |
Points at the nearest upcoming expiry date (0 if no upcoming expiry). |
nearestExpiryDateTimeUTC |
UTC timestamp of that nearest expiry (empty string if none). |
3. Grant points (earn)¶
Typically called when a customer completes a purchase. refCode is your
idempotency key — use something unique to the order (e.g. the order ID).
If you replay the same refCode, the loyalty service recognises it and
does not double-credit.
curl -sS -X POST "$BASE/api/v1/loyalty/earn-points" \
-H "X-Api-Key: $KEY" -H "Content-Type: application/json" \
-d "{
\"customerUuid\": \"$CUST\",
\"points\": 25,
\"refCode\": \"order-12345-earn\"
}" | jq .
{
"code": 0,
"isSuccess": true,
"message": "grant_points_success"
}
Other fields you can pass:
| Field | Required | Notes |
|---|---|---|
customerUuid |
Yes | Customer's UUID in loyalty. |
points |
Yes | Positive integer. |
refCode |
Yes | Your idempotency key. |
displayNotes |
No | Customer-facing note. |
internalNotes |
No | Internal audit note. |
expiryDatetime |
No | Explicit expiry (UTC, YYYY-MM-DD HH:MM:SS). When omitted, loyalty computes the expiry from the tenant's program config. |
A replayed refCode returns grant_points_already_processed instead
of grant_points_success — that's expected and safe.
3b. Earn points from an amount (let loyalty do the math)¶
This is the canonical partner earn API (ADR-030). Instead of sending a
points value, you send the transaction amount (already net of
discounts) and a sourceType; loyalty-service works out how many points
it earns using the standard earn rate:
pointsEarned = floor( amount / POINTS_CONVERT × tierMultiplier )
The earn rate and tier perks live in loyalty's configuration, so your POS never has to mirror that logic.
amount is a decimal in your tenant's currency — not cents
Send the transaction value as a normal decimal in the major currency
unit, exactly as it appears on the receipt. For a €9.45 order, send
"amount": 9.45 — not 945. POINTS_CONVERT is configured in the
same unit, so the two always line up. This endpoint has no
minor-unit / cents convention.
Advances loyalty tiers
This endpoint also accrues tier spending from the same amount, in
one atomic transaction — so a partner order progresses customers through
tiers and triggers tier-upgrade rewards/notifications. Even an order that
earns 0 points (below the conversion threshold) still accrues tier
spend. A 0-point call writes no points-ledger entry, so it does not
consume the refCode on the points ledger — a later call with the same
refCode and a qualifying amount will still credit points. The tier spend
for that order is recorded once and is never double-counted on replay.
Bonus campaigns are not applied
Bonus-point campaigns apply only to orders placed via the Cata App.
On this partner endpoint bonusMultiplier is always 0 and
effectiveMultiplier == tierMultiplier.
curl -sS -X POST "$BASE/api/v1/loyalty/earn-points-by-amount" \
-H "X-Api-Key: $KEY" -H "Content-Type: application/json" \
-d "{
\"customerUuid\": \"$CUST\",
\"amount\": 1000,
\"refCode\": \"12075406\",
\"sourceType\": \"JAMEZZ\"
}" | jq .
{
"code": 0,
"isSuccess": true,
"message": "grant_points_success",
"data": {
"pointsEarned": 10,
"tierMultiplier": 1.0,
"pointConvert": 100.0,
"bonusMultiplier": 0.0,
"effectiveMultiplier": 1.0,
"tierUpgraded": false
}
}
data.pointsEarned tells you exactly how many points were credited;
data.tierUpgraded is true when the order promoted the customer to a new tier.
Fields you can pass:
| Field | Required | Notes |
|---|---|---|
customerUuid |
Yes | Customer's UUID in loyalty. |
amount |
Yes | Transaction amount as a decimal in your tenant's currency (e.g. €9.45 → 9.45, not cents/945). Already net of discounts. Must be > 0. |
refCode |
Yes | Your order id / idempotency key. Composed server-side as sourceType:refCode. |
sourceType |
Yes | Partner source: REVEL, LIGHTSPEED, ATLAS, or JAMEZZ. |
displayNotes |
No | Customer-facing note. |
internalNotes |
No | Internal audit note. |
expiryDatetime |
No | Explicit expiry (UTC, YYYY-MM-DD HH:MM:SS). When omitted, loyalty computes the expiry from the tenant's program config. |
Idempotency is per refCode on the points ledger: once points have been
credited for a refCode, replaying it returns grant_points_already_processed
(no double credit). A 0-point call does not consume the refCode (see the tier
note above), so it can still credit points on a later qualifying amount.
Which earn endpoint should I use?
earn-points— you already know the points value (e.g. a fixed manual reward, or a partner that owns its own earn logic).earn-points-by-amount— you have a spend amount and want Cata's loyalty program to decide the points and advance tiers. This is the standard path for partner sales.
4. Redeem points (use)¶
Called when a customer spends points (e.g. at checkout). FIFO batch consumption — the oldest points are used first.
curl -sS -X POST "$BASE/api/v1/loyalty/use-points" \
-H "X-Api-Key: $KEY" -H "Content-Type: application/json" \
-d "{
\"customerUuid\": \"$CUST\",
\"points\": 10,
\"refCode\": \"order-12345-redeem\"
}" | jq .
{
"code": 0,
"isSuccess": true,
"message": "use_points_success"
}
refCode is idempotent here too — a replay returns a safe "already
processed" acknowledgement without a second debit.
5. Verify the balance change¶
After earning 25 and redeeming 10, the balance should move by +15.
curl -sS "$BASE/api/v1/loyalty/balance?customerUuid=$CUST" \
-H "X-Api-Key: $KEY" | jq .
{
"code": 0,
"isSuccess": true,
"message": "OK",
"balance": 145,
"numOfPointsWillExpire": 25,
"nearestExpiryDateTimeUTC": "2026-12-31T15:59:59Z"
}
Starting balance was 130 → +25 earned → −10 redeemed → 145. The
numOfPointsWillExpire reflects points expiring at the nearest
upcoming expiry date.
Error handling¶
Always check isSuccess, not just the HTTP status
The loyalty service returns HTTP 200 for business errors (e.g.
insufficient balance, unknown customer). The failure signal lives
in the response body: isSuccess: false with a non-zero code and
a descriptive message. Treat a 200 with isSuccess: false as a
failed operation — do not assume success on HTTP 2xx alone.
The proxy layer's client-side validation (missing fields, non-positive
points) still returns HTTP 4xx with the standard error envelope.
Insufficient balance → 200 with code: 1012¶
Redeeming more points than the customer has available.
curl -sS -i -X POST "$BASE/api/v1/loyalty/use-points" \
-H "X-Api-Key: $KEY" -H "Content-Type: application/json" \
-d "{
\"customerUuid\": \"$CUST\",
\"points\": 99999,
\"refCode\": \"redeem-999\"
}"
HTTP/1.1 200 OK
Content-Type: application/json
{
"code": 1012,
"isSuccess": false,
"message": "insufficient_points"
}
No points are deducted. The customer's balance is unchanged. Safe to
retry with a lower points value (use a new refCode — reusing
the rejected one is not guaranteed to re-run).
Non-positive points → 400¶
curl -sS -i -X POST "$BASE/api/v1/loyalty/use-points" \
-H "X-Api-Key: $KEY" -H "Content-Type: application/json" \
-d "{\"customerUuid\":\"$CUST\",\"points\":0,\"refCode\":\"bad\"}"
HTTP/1.1 400 Bad Request
{
"error": {
"code": 400,
"message": "invalid request",
"details": "points must be greater than 0"
}
}
Client-side validation — the proxy rejects the request before
forwarding to loyalty. Same shape applies to missing customerUuid
or missing refCode.
Invalid / missing API key → 401¶
curl -sS -o /dev/null -w "HTTP %{http_code}\n" \
"$BASE/api/v1/auth/whoami" -H "X-Api-Key: wrong"
HTTP 401
All loyalty endpoints reject missing/invalid/expired keys with 401 and
this body:
{
"error": {
"code": 401,
"message": "missing api key",
"details": "X-Api-Key header is required"
}
}
Do not retry — re-authenticate.
Missing customerUuid → 400¶
curl -sS -o /dev/null -w "HTTP %{http_code}\n" \
"$BASE/api/v1/loyalty/balance" -H "X-Api-Key: $KEY"
HTTP 400
{
"error": {
"code": 400,
"message": "missing required field",
"details": "customerUuid query parameter is required"
}
}
Validation errors return 400 without hitting the upstream loyalty
service — fix the request and retry.
Other status codes¶
| HTTP | When | Retry? |
|---|---|---|
400 |
Body/query validation failed. | No — fix input. |
401 |
API key missing / invalid / expired. | No — re-auth. |
404 |
Customer not found in this tenant. | Depends. |
502 |
Loyalty backend returned an error. | Yes, with backoff. |
503 |
Tenant hasn't finished loyalty onboarding. | No — tenant admin action needed. |
504 |
Loyalty backend timed out. | Yes, with backoff. |
Idempotency — at a glance¶
- Use a deterministic
refCodetied to your own system (order ID, transaction ID, event UUID). - Earning and redeeming with the same
refCodeis safe to replay — the loyalty service returns an acknowledgement without a second effect. - Balance reads are naturally idempotent (no
refCode).
That's the full loyalty round-trip. Reach out if you hit a response shape that isn't documented here.
See also¶
- Full API reference (all partner endpoints, schemas, examples): https://apidocs.cata.sg/