Saving Circles
Saving Circles is a group savings primitive: a fixed-size group where every member contributes the same amount on a regular cadence, and on each cadence one member receives the entire pool. The circle rotates through all members until everyone has received a payout. This document is an integrator reference — endpoints, request shapes, money flow, lifecycle, errors, and limits you will need to work around on your side.
| Property | Value |
|---|---|
| Product | Saving Circles · rotating group savings (ROSCA / arisan) |
| API base | https://<your-tenant>/v1 |
| Auth | Bearer token · per-endpoint permissions |
| Funds | Single currency · IDR · integer minor units as string |
| Audience | Partner backend engineers integrating Moria’s REST API |
What is a Saving Circle
Section titled “What is a Saving Circle”A Saving Circle is the digital version of an arisan (Indonesia) / ROSCA / tanda / chit fund. A creator sets a target payout amount (lump_sum) and a member count (capacity). Each member’s contribution is fixed at lump_sum / capacity. Every period, every member contributes; once the pool is fully funded, the entire pool is paid out to the next member in the queue.
The product is deliberately rigid: equal contributions, round-robin payouts, fixed cadence. That rigidity is what makes the math fair and the audit story clean. If your use case needs variable contributions, weighted payouts, or partial exits, see the What is not supported section for workarounds.
The whole mechanic in one picture
Section titled “The whole mechanic in one picture”flowchart LR
A([Creator]):::actor --> B[Create circle<br/>set lump_sum + capacity]
B --> C[Invite members<br/>up to capacity]
C --> D{Members joined?}
D -- not yet --> C
D -- yes --> E[(Circle pool<br/>balance: 0)]:::pool
E --> F[Turn N starts<br/>every member contributes<br/>contribution_amount]
F --> G{Pool == lump_sum?}
G -- not yet --> F
G -- yes --> H[Payout to<br/>member on turn N]:::payout
H --> I[current_turn += 1]
I --> J{All turns done?}
J -- not yet --> F
J -- yes --> K([Completed]):::done
classDef actor fill:#E8E1FF,stroke:#3C2C96,stroke-width:2px,color:#1E1B4B
classDef pool fill:#FFF3D0,stroke:#F4BE27,stroke-width:2px,color:#8B5A00
classDef payout fill:#D4E8DB,stroke:#2E7D5B,stroke-width:2px,color:#1E1B4B
classDef done fill:#FDF8E8,stroke:#3C2C96,stroke-width:2px,color:#1E1B4B
How money moves
Section titled “How money moves”flowchart LR
subgraph Members["Members · main balance"]
M1[(Member 1<br/>main balance)]:::wallet
M2[(Member 2<br/>main balance)]:::wallet
M3[(Member 3<br/>main balance)]:::wallet
MN[(Member N<br/>main balance)]:::wallet
end
P[(Circle pool<br/>holding balance)]:::pool
subgraph Payout["Current turn recipient"]
R[(Member on turn N<br/>main balance)]:::recipient
end
M1 -- contribution_amount --> P
M2 -- contribution_amount --> P
M3 -- contribution_amount --> P
MN -- contribution_amount --> P
P == lump_sum payout ==> R
classDef wallet fill:#FDF8E8,stroke:#3C2C96,stroke-width:1.5px,color:#1E1B4B
classDef pool fill:#FFF3D0,stroke:#F4BE27,stroke-width:2.5px,color:#8B5A00
classDef recipient fill:#D4E8DB,stroke:#2E7D5B,stroke-width:2.5px,color:#1E1B4B
Three balance roles: each member’s main balance (where contributions are debited and payouts land), the circle pool (a circle-specific holding balance, created automatically on POST /v1/saving-circles, starting at zero), and the turn-N recipient (the single member whose main balance receives the full lump_sum this round). The pool sits idle between turns — it only grows during an active contribution window and drains fully on the payout step.
Circle lifecycle
Section titled “Circle lifecycle”stateDiagram-v2
direction LR
[*] --> new: POST /saving-circles
new --> new: add members
new --> active: first contribution
new --> cancelled: DELETE (no contributions yet)
active --> active: contribute · payout · rotate
active --> completed: all turns done
active --> cancelled: stopped mid-cycle
completed --> [*]
cancelled --> [*]
note right of new
Members can be added.
No funds have moved.
end note
note right of active
current_turn advances
as the pool fills and pays out.
end note
Endpoint reference
Section titled “Endpoint reference”| Method | Path | Purpose | Permission |
|---|---|---|---|
POST | /v1/saving-circles | Create a circle (also provisions the pool) | create-saving-circle |
GET | /v1/saving-circles | List circles · paginated · filterable | read-saving-circle |
GET | /v1/saving-circles/:id | Fetch one circle by id | read-saving-circle |
PATCH | /v1/saving-circles/:id | Update name · capacity · start_date · amount · current_turn · visibility | update-saving-circle |
DELETE | /v1/saving-circles/:id | Delete (only valid if no contributions exist) | delete-saving-circle |
POST | /v1/saving-circles/members | Add members to a circle | update-saving-circle |
POST | /v1/saving-circles/contribute | Record a contribution for the current turn | create-saving-circle |
GET | /v1/summary/saving-circles | Aggregate statistics for an organisation | read-saving-circle |
The list endpoint accepts page, limit, and filter parameters (status, organization_id, user_id, account_id). All responses are object-wrapped under data; pagination lives inside data. All write endpoints require a bearer token; POST /v1/saving-circles additionally requires the caller to own (or have permission over) the account_id that funds the circle.
Creating a circle
Section titled “Creating a circle”Request — POST /v1/saving-circles
{ "account_id": "5b3a...c1", "name": "Office Arisan 2026", "capacity": 10, "amount": "240000000", "start_date": "2026-06-28"}account_id is the main balance that anchors the circle (usually the creator’s). capacity is both the member count and the number of turns. amount is the target payout per turn — the lump_sum each member will receive.
Response — 201 Created
{ "data": { "id": "9f1c...4d", "name": "Office Arisan 2026", "capacity": 10, "amount": "240000000", "contribution_amount": "24000000", "start_date": "2026-06-28", "current_turn": 0, "status": "new", "visibility": "private", "pool_account_id": "a8e2...7b" }}contribution_amount is derived from: amount / capacity. The server returns a dedicated pool_account_id for the circle pool — you do not have to create it yourself.
Adding members
Section titled “Adding members”Request — POST /v1/saving-circles/members
{ "saving_circle_id": "9f1c...4d", "account_ids": [ "5b3a...c1", "7c4e...d2", "2f8a...91", "1bd0...33" ]}account_ids is the list of main balance accounts being enrolled. Order matters — it defines the payout rotation order. The creator is usually the first account in the list.
You can call this endpoint multiple times to add members in batches, as long as the total does not exceed capacity. Once any contribution is recorded, the member list freezes for the cycle — there is no remove-member endpoint, and no membership transfers between accounts.
Contribution flow — two rails
Section titled “Contribution flow — two rails”A · Balance rail (synchronous)
POST /v1/saving-circles/contribute{ "saving_circle_id": "9f1c...4d", "contribution_amount": "24000000"}The member’s main balance is debited immediately. The pool is credited on the same request. The contribution row is written with status: completed.
Latency: one round-trip. Failure modes: insufficient balance → 400; account is not a circle member → 403; wrong amount → 400.
B · Gateway rail (asynchronous)
POST /v1/saving-circles/contribute{ "saving_circle_id": "9f1c...4d", "contribution_amount": "24000000", "use_payment_gateway": true}A payment order is created with an external payment gateway. The contribution row starts at status: pending. When the gateway callback arrives, the status flips to completed or failed.
Latency: end-to-end depends on the payment method (VA, e-wallet, card). Poll GET /v1/saving-circles/:id or subscribe to the webhook to track completion.
The amount you send must equal the contribution_amount stored on the circle. There is no partial-pay or over-pay path. The same member contributing twice for the same turn will be rejected. When every member’s contribution for the current turn reaches completed, the payout runs automatically — see the next diagram.
One turn, end to end
Section titled “One turn, end to end”sequenceDiagram
autonumber
participant M1 as Member 1 (balance rail)
participant M2 as Member 2 (gateway rail)
participant API as Saving Circles API
participant Pool as Circle Pool
participant PG as Payment Gateway
participant Rcv as Turn-N Recipient
Note over API,Pool: Turn N starts. Pool balance = 0.
M1->>API: POST /saving-circles/contribute<br/>{contribution_amount}
API->>API: Debit main balance (synchronous)
API->>Pool: Credit pool
API-->>M1: 201 · status: completed
M2->>API: POST /saving-circles/contribute<br/>{contribution_amount}
API->>PG: Create payment order
API-->>M2: 201 · status: pending
PG-->>API: Callback · payment succeeded
API->>API: Debit main balance
API->>Pool: Credit pool
API->>API: status: completed
Note over Pool: Pool balance reaches lump_sum
API->>Rcv: Transfer lump_sum to main balance
API->>API: current_turn += 1
Note over API: Next member is now in the payout slot
Payout rotation
Section titled “Payout rotation”flowchart LR
Start([Start · current_turn = 0]):::start
M1[Turn 1<br/>Member 1 receives]:::turn
M2[Turn 2<br/>Member 2 receives]:::turn
M3[Turn 3<br/>Member 3 receives]:::turn
Mdot[...]:::mute
MN[Turn N<br/>Member N receives]:::turn
Done([Circle completed]):::done
Start --> M1 --> M2 --> M3 --> Mdot --> MN --> Done
subgraph Rule["Rotation rules"]
R1["recipient_index = current_turn % capacity<br/>order = member join order (earliest first)<br/>missed contributions do not skip a member's turn"]:::rule
end
classDef start fill:#E8E1FF,stroke:#3C2C96,stroke-width:2px,color:#1E1B4B
classDef turn fill:#FDF8E8,stroke:#3C2C96,stroke-width:1.5px,color:#1E1B4B
classDef done fill:#D4E8DB,stroke:#2E7D5B,stroke-width:2px,color:#1E1B4B
classDef mute fill:#F5F0DC,stroke:#6B6259,stroke-width:1px,color:#6B6259
classDef rule fill:#FFF8E6,stroke:#B8860B,stroke-width:1px,color:#8B5A00
Rotation is strict round-robin by join order. The member at position current_turn % capacity is the recipient. A missed contribution does not skip a member’s turn — the rotation continues, and the missing contributor is tracked separately so your front-end can surface it. There is no priority override at the API level; if you need a different order, set it explicitly through the account_ids order when adding members.
Common error responses
Section titled “Common error responses”| Status | When | Body shape (illustrative) |
|---|---|---|
400 | Validation failure — wrong contribution_amount, capacity exceeded, malformed body | { "message": "contribution_amount does not match circle setting" } |
400 | DELETE attempted on a circle that already has contributions | { "message": "cannot delete a circle with recorded contributions" } |
401 | Bearer token missing or expired | { "message": "Unauthorized" } |
403 | Token valid, but caller lacks the permission required for this endpoint | { "message": "Forbidden" } |
403 | Contributing from an account that is not a member of the circle | { "message": "account is not a member of this saving circle" } |
404 | Circle id not found, or filtered out by tenancy/visibility | { "message": "saving circle not found" } |
409 | Duplicate contribution for the same member on the same turn | { "message": "contribution already recorded for this turn" } |
422 | Insufficient main balance for a balance-rail contribution | { "message": "insufficient balance" } |
All error responses share the same envelope as success: { "status": "error", "message": ..., "data": null }. Treat status and the HTTP status code as the source of truth; message is human-readable and may change.
Authorization model
Section titled “Authorization model”Permission matrix
| Endpoint | Permission |
|---|---|
| create · contribute | create-saving-circle |
| list · get · summary | read-saving-circle |
| patch · add members | update-saving-circle |
| delete | delete-saving-circle |
What is not supported (and how to work around it)
Section titled “What is not supported (and how to work around it)”Integration checklist
Section titled “Integration checklist”Pre-launch — verify on your side
- Token plumbing. Every Saving Circles call carries
Authorization: Bearer <token>; an expired token returns401, not500. - Permission seeding. The role your users hold has
create-saving-circle/read-saving-circle/update-saving-circle/delete-saving-circleas needed. - Account-id source of truth. Your UI knows which
account_idrepresents the “main balance” for each user — that is what gets sent inaccount_ids. - Ownership guard. If only the creator should be allowed to add members, enforce that check on your side before
POST /v1/saving-circles/members. - Idempotency key. Wrap contribute calls with a client-side idempotency key to avoid double-debit on retries.
Post-launch — observability & support
- Gateway-rail polling. For
pendingcontributions, pollGET /v1/saving-circles/:idor wire up the webhook so you do not display stale state. - Insufficient-balance UX. Catch
422on contribute and surface a top-up CTA. - Turn visibility. Display
current_turnand the next recipient in your UI — these are the most frequently asked fields. - Cancellation flow. Decide your product policy for cancelling an
activecircle; the API allows it, but the audit story and refund handling are your design problems. - Summary endpoint. Hit
GET /v1/summary/saving-circlesfor organisation admin dashboards instead of paginating the full list.
What works well · what to know
Section titled “What works well · what to know”Saving Circles is a small, opinionated API. The integration work is mostly UX, role mapping, and a handful of ownership checks the API leaves to you. Once those are in place, the account rail underneath, the payment gateway integration, and the audit trail are already wired up — your job is to surface the rotation in a way your users can trust.