Earning and spending points
Every point the member sees lives in core’s ledger. Your mechanics decide when points move and how many; core records it, enforces “never below zero”, and makes retries safe.
Collect and redeem
Section titled “Collect and redeem”sequenceDiagram
autonumber
actor M as Member
box rgb(246,241,233) Sota Platforms
participant W as Your widget page
participant BE as Your backend
end
box rgb(227,244,243) FINNS platform
participant CORE as Core
participant APP as FINNS app
end
M->>W: Tap "Collect 10"
Note over W: nowrap:New Idempotency-Key per tap<br/>(reused if this tap is retried)
W->>BE: POST /api/v1/points/adjust {direction: credit}<br/>Bearer widget token · Idempotency-Key: k
Note over BE: nowrap:Session → extId.<br/>Amount decided here, never by the page.<br/>Core key derived from (extId, k)
BE->>CORE: POST /api/v1/points/credits<br/>{extId, points: 10, reason, reference}<br/>Idempotency-Key: derived
Note over CORE: nowrap:One transaction: claim key,<br/>move balance, two ledger entries, feed event
CORE-->>BE: 201 {transactionId, points: 10, balance, seq, …}
BE-->>W: {balance}
W->>APP: changed {topics: ["points"]}
APP->>CORE: GET /api/v1/app/points
CORE-->>APP: {balance}
Note over APP: nowrap:Card animates the new balance
Redeeming is the same with /debits. If the balance is too low, core answers 409 insufficient_points and nothing changes.
Rules that keep the ledger right
Section titled “Rules that keep the ledger right”| Rule | Why |
|---|---|
| Your backend decides the amount; the page only says what happened | A page can be tampered with; your backend can’t |
Idempotency keys scoped to the member (derive core’s key from extId + the page’s key) |
One member’s page can never collide with, or replay, another’s posting |
Retry with the same key on timeout, 5xx or 429 |
Core replays the original answer with Idempotent-Replayed: true instead of posting twice |
Same key, different request → 422 idempotency_mismatch |
Treat as a bug, not a retry |
| Refund your own postings only, once, in full | Check-in, booking and staff points are not_refundable |
reason and reference are yours |
Use them to tie a posting to your campaign, draw or prize (reason matches [A-Za-z0-9_.:-]{1,32}) |
Staying in sync
Section titled “Staying in sync”You will usually want your own copy of balances (leaderboards, eligibility rules). Don’t poll every member; read core’s event feed, which carries every posting from every source, in commit order.
flowchart LR
classDef ours fill:#13686C,stroke:#0B4A4D,color:#ffffff
classDef ext fill:#F3ECDF,stroke:#B9A88B,color:#12191B
gate["Gate check-in"]:::ext --> L
book["Paid booking"]:::ext --> L
staff["Staff adjustment"]:::ext --> L
you["Your postings"]:::ext --> L
L[("Core ledger")]:::ours -- "GET /events?after=cursor<br/>customer.created · points.posted" --> mirror["Your balance mirror<br/>keep highest seq per member"]:::ext
L -- "GET /reconciliation/{date}<br/>daily totals + hash" --> recon["Your daily check"]:::ext
- Feed:
GET /api/v1/points/events?after=<next>&limit=<1–500>. Omitafterto start from the beginning; events are kept forever, so you can always rebuild. Order across members isn’t each member’s posting order: keep the highestseqper member. - Reconciliation:
GET /api/v1/points/reconciliation/{YYYY-MM-DD}(Bali date) returns credits, debits and refunds as{count, points}, plus a SHA-256 over the day’s sorted transaction ids. Final once the Bali day has ended. - One member, right now:
GET /api/v1/points/customers/{extId}→{extId, balance, seq}.
Internal and confidential. Share partner sections only as partner builds (pnpm build:partner).

