Skip to content

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. acmeacme.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.45not 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 points400

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 customerUuid400

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 refCode tied to your own system (order ID, transaction ID, event UUID).
  • Earning and redeeming with the same refCode is 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