Skip to content

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.

PropertyValue
ProductSaving Circles · rotating group savings (ROSCA / arisan)
API basehttps://<your-tenant>/v1
AuthBearer token · per-endpoint permissions
FundsSingle currency · IDR · integer minor units as string
AudiencePartner backend engineers integrating Moria’s REST API

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.


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

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.


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

MethodPathPurposePermission
POST/v1/saving-circlesCreate a circle (also provisions the pool)create-saving-circle
GET/v1/saving-circlesList circles · paginated · filterableread-saving-circle
GET/v1/saving-circles/:idFetch one circle by idread-saving-circle
PATCH/v1/saving-circles/:idUpdate name · capacity · start_date · amount · current_turn · visibilityupdate-saving-circle
DELETE/v1/saving-circles/:idDelete (only valid if no contributions exist)delete-saving-circle
POST/v1/saving-circles/membersAdd members to a circleupdate-saving-circle
POST/v1/saving-circles/contributeRecord a contribution for the current turncreate-saving-circle
GET/v1/summary/saving-circlesAggregate statistics for an organisationread-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.


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.


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.


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.


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

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.


StatusWhenBody shape (illustrative)
400Validation failure — wrong contribution_amount, capacity exceeded, malformed body{ "message": "contribution_amount does not match circle setting" }
400DELETE attempted on a circle that already has contributions{ "message": "cannot delete a circle with recorded contributions" }
401Bearer token missing or expired{ "message": "Unauthorized" }
403Token valid, but caller lacks the permission required for this endpoint{ "message": "Forbidden" }
403Contributing from an account that is not a member of the circle{ "message": "account is not a member of this saving circle" }
404Circle id not found, or filtered out by tenancy/visibility{ "message": "saving circle not found" }
409Duplicate contribution for the same member on the same turn{ "message": "contribution already recorded for this turn" }
422Insufficient 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.


Permission matrix

EndpointPermission
create · contributecreate-saving-circle
list · get · summaryread-saving-circle
patch · add membersupdate-saving-circle
deletedelete-saving-circle

What is not supported (and how to work around it)

Section titled “What is not supported (and how to work around it)”

Pre-launch — verify on your side

  1. Token plumbing. Every Saving Circles call carries Authorization: Bearer <token>; an expired token returns 401, not 500.
  2. Permission seeding. The role your users hold has create-saving-circle / read-saving-circle / update-saving-circle / delete-saving-circle as needed.
  3. Account-id source of truth. Your UI knows which account_id represents the “main balance” for each user — that is what gets sent in account_ids.
  4. Ownership guard. If only the creator should be allowed to add members, enforce that check on your side before POST /v1/saving-circles/members.
  5. Idempotency key. Wrap contribute calls with a client-side idempotency key to avoid double-debit on retries.

Post-launch — observability & support

  1. Gateway-rail polling. For pending contributions, poll GET /v1/saving-circles/:id or wire up the webhook so you do not display stale state.
  2. Insufficient-balance UX. Catch 422 on contribute and surface a top-up CTA.
  3. Turn visibility. Display current_turn and the next recipient in your UI — these are the most frequently asked fields.
  4. Cancellation flow. Decide your product policy for cancelling an active circle; the API allows it, but the audit story and refund handling are your design problems.
  5. Summary endpoint. Hit GET /v1/summary/saving-circles for organisation admin dashboards instead of paginating the full list.

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.