Paid bookings earn points
When a member pays for a booking, tell core once. Core credits points in the same request and keeps no other booking data.
Each booking is credited once per bookingId, however often you retry. How many points a booking earns is configured in core. As an example only, at 1 point per IDR 10,000 paid (rounded down), a IDR 3,155,000 booking earns 315 points. Don’t hard-code a rate: read the points from core’s answer.
The flow
Section titled “The flow”sequenceDiagram
autonumber
actor G as Guest
box rgb(246,241,233) Booking engine
participant BE as Booking site
participant PAY as Your payment provider
end
box rgb(227,244,243) FINNS platform
participant CORE as Core
participant APP as FINNS app
end
G->>BE: Pay (after a fresh sign-in)
BE->>PAY: Charge
PAY-->>BE: Paid
BE->>CORE: POST /api/v1/booking/bookings<br/>{sub, bookingId, totalIdr}
Note over CORE: nowrap:points = floor(totalIdr / 10,000)<br/>idempotency key booking:<bookingId><br/>one transaction: ledger + feed
CORE-->>BE: 204
BE-->>G: Booking confirmed
APP->>CORE: GET /api/v1/app/points (app refreshes)
CORE-->>APP: New balance
Request
Section titled “Request”POST /api/v1/booking/bookings HTTP/1.1Host: test-api.finnsbali.comAuthorization: Bearer <booking API token>Content-Type: application/json
{ "sub": "6f1c2a…", "bookingId": "BK-2026-10-13-0042", "totalIdr": 3155000 }| Field | Rule |
|---|---|
sub |
The member’s Keycloak subject, from the handoff redeem or your ID token. Printable ASCII, 1–128 characters. |
bookingId |
Your stable id for the booking. [A-Za-z0-9_-]{1,64}. |
totalIdr |
Amount actually paid, in whole rupiah. A JSON integer ≥ 0; fractions and strings are refused. |
Answer: 204 on success (including a repeat), 422 invalid with fields, or 400 malformed.
Exactly once
Section titled “Exactly once”flowchart TB
classDef ok fill:#E3F4F3,stroke:#13686C,color:#12191B
classDef bad fill:#FFF4E8,stroke:#E9724C,color:#12191B
classDef step fill:#ffffff,stroke:#D9CFBE,color:#12191B
a["POST /bookings {sub, bookingId, totalIdr}"]:::step --> b{"bookingId seen before?"}
b -- "no" --> c{"totalIdr ≥ 10,000?"}
c -- "yes" --> d["Credit floor(total / 10,000)<br/>204"]:::ok
c -- "no" --> e["Nothing to credit<br/>204"]:::ok
b -- "yes, same total" --> f["Replay: nothing changes<br/>204"]:::ok
b -- "yes, different total" --> g["422 idempotency_mismatch"]:::bad
- Retry until you get an answer. On a timeout, network error,
5xxor429, send the same request again with backoff. Core makes that safe; never generate a newbookingIdfor a retry. - Report from your backend, after payment is confirmed, not from the browser.
- Persist the report (outbox) if core is unreachable for long, and drain it later. The points are not time-critical; losing them is.
Internal and confidential. Share partner sections only as partner builds (pnpm build:partner).

