Skip to content

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.

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.

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})

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>. Omit after to 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 highest seq per 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).