SACCO
A SACCO project keeps a cooperative’s members and their money: share capital (paid in, returned when a member leaves), savings (deposits they can withdraw), loans (backed by guarantors) and dividends. Every movement is booked in the ledger, so balances can never go negative and the books always balance. Sensitive actions wait for a second person’s approval.
All /api/v1 endpoints work with an API key (a teller app, say) or a signed-in staff member’s session. Each environment (test/live) has its own SACCO.
Staff and approvals
Section titled “Staff and approvals”The project’s owner adds staff: people who signed up to zirzir, each with a role.
curl -X POST -H "Authorization: Bearer $OWNER_SESSION" -H "Content-Type: application/json" \ http://localhost:8080/api/projects/$PROJECT/staff| Role | Can |
|---|---|
maker | Operate: register members, take deposits, request withdrawals |
checker | Everything a maker can, and approve or reject what others requested |
admin | Everything, and manage the project: API keys, provider credentials, staff, SACCO settings |
The owner has every role. GET .../staff lists the staff, DELETE .../staff/$USER_ID removes one, and posting an existing member’s email changes their role.
Staff call the same API as your apps, with their session and the project in headers:
curl -H "Authorization: Bearer $SESSION" -H "X-Zirzir-Project: $PROJECT" -H "X-Zirzir-Environment: live" \ http://localhost:8080/api/v1/approvals?status=pendingMaker-checker
Section titled “Maker-checker”A request that needs approval is answered with 202 Accepted and the entry’s status awaiting_approval; nothing moves yet. A checker then decides it:
curl -X POST -H "Authorization: Bearer $CHECKER_SESSION" -H "X-Zirzir-Project: $PROJECT" \ -H "X-Zirzir-Environment: live" -H "Content-Type: application/json" \ -d '{"note": "member present with ID"}' \ http://localhost:8080/api/v1/approvals/$APPROVAL/approve- Only a signed-in owner, admin or checker of the project can decide, and never the person who requested it, nor the person who created the API key it was requested with. API keys never approve, and neither do zirzir platform operators.
rejectneeds anote. The requester (or an admin) cancancela pending request.- Approving carries the action out at once; the response has the approval and the resulting entry. If it cannot be carried out (the money is no longer there), the entry fails with the reason, and nothing moves.
- Each approval records who requested and who decided it, and when.
Settings
Section titled “Settings”The settings decide what needs a checker, so only the owner or an admin, signed in, changes them (or does it in the dashboard); API keys can read them.
curl -X PUT -H "Authorization: Bearer $ADMIN_SESSION" -H "X-Zirzir-Project: $PROJECT" \ -H "X-Zirzir-Environment: live" -H "Content-Type: application/json" \ -d '{"currency": "ETB", "min_kyc_tier": 1, "entrance_fee_minor": 5000, "min_share_capital_minor": 100000, "withdrawal_approval_minor": 100000}' \ http://localhost:8080/api/v1/sacco/settings| Field | Meaning |
|---|---|
currency | The SACCO’s books are in one currency. It cannot change once money has moved. |
min_kyc_tier | The KYC tier a customer needs to join (default 1). |
entrance_fee_minor | Charged in cash when a member joins (default 0). |
min_share_capital_minor | The share capital members are expected to hold. |
utc_offset_minutes | The SACCO’s time zone, minutes east of UTC (180 for East Africa): installments fall due, and days past due count, on its local dates. Default 0. |
withdrawal_approval_minor | A withdrawal that takes the member’s withdrawals for the day above this waits for a checker, so splitting one into smaller ones does not avoid approval. null removes the threshold. |
Fields you leave out keep their value (the first call needs currency). Settings decide what needs approval, so only the owner or an admin, signed in, can change them: not an API key.
Members
Section titled “Members”Members are customers who joined:
curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{"customer_id": "cus_…", "pay_entrance_fee": "cash"}' \ http://localhost:8080/api/v1/sacco/members- Members are numbered
M00001,M00002, …; sendmember_noto use your own numbers. pay_entrance_fee: "cash"confirms the fee was paid; it is booked with the membership. Joining again returns the same member ("replayed": true).GET /api/v1/sacco/members/$MEMBERshowsshares,savingsandsavings_available(savings not held by a loan guarantee).POST .../suspendand.../reactivate: a suspended member can still pay in, but nothing is paid out to them.- A customer who is a member cannot be closed until their exit is approved.
Money in and out
Section titled “Money in and out”Amounts are amount (an exact decimal string like "1500.00") or amount_minor, in the SACCO’s currency. Every request needs an idempotency_key (keys starting with payment:, entrance:, loan: or dividend: are reserved): repeating a request returns the original entry with "replayed": true, and a key reused for a different request is refused.
# Cash deposit into savingscurl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{"amount": "2000.00", "idempotency_key": "dep-1001"}' \ http://localhost:8080/api/v1/sacco/members/$MEMBER/deposits
# Cash withdrawal (above the threshold: 202, waits for a checker)curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{"amount": "1200.00", "idempotency_key": "wd-1001"}' \ http://localhost:8080/api/v1/sacco/members/$MEMBER/withdrawals
# Shares, paid in cash or moved from savingscurl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{"from": "savings", "amount": "500.00", "idempotency_key": "sh-1001"}' \ http://localhost:8080/api/v1/sacco/members/$MEMBER/sharesEach request returns an entry: kind, amount, status (posted, awaiting_approval, failed with an error, rejected, cancelled; briefly pending or posting while it is booked) and who requested it. Status checks happen again when an approved entry is carried out: a withdrawal approved after the member was suspended, or their customer frozen, fails. A movement the money is not there for fails with the reason, for example “the member’s available savings (300.00 ETB) are not enough” or “the tills hold only 850.00 ETB in cash”. GET /api/v1/sacco/members/$MEMBER/entries is the member’s history; GET /api/v1/sacco/entries?status=awaiting_approval lists all.
Paying in with mobile money
Section titled “Paying in with mobile money”Create a payment with the member in its metadata; when it completes, it is credited to their savings (or shares):
curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{"provider": "telebirr", "reference": "SAV-77", "amount": 300.00, "currency": "ETB", "customer_phone": "0911000010", "metadata": {"sacco_member_id": "mem_…", "sacco_purpose": "savings"}}' \ http://localhost:8080/api/v1/paymentssacco_purposeissavings(default) orshares. The member and currency are checked when the payment is created; a SACCO payment cannot also split or top up a wallet.- The payment is credited exactly once, after it is booked in the ledger.
- A refund is allowed only while the payment has not been credited, and only for the whole payment: the credit is cancelled while the refund runs. If the refund fails, is cancelled or is never created, the credit is reopened within about ten minutes and goes to the member after all. Once credited, the money is the member’s: pay it back with a withdrawal.
- A payment for a member who has meanwhile started leaving is not credited: its entry fails, and you refund it.
Cash in the tills
Section titled “Cash in the tills”The tills are an account of their own (cash_on_hand): cash deposits add to it, cash withdrawals take from it, and it cannot go negative. Money paid in by mobile money stays with the provider, not in the tills.
Count cash in (an opening balance, a surplus) or out (a shortage) with a cash adjustment. It always needs a checker, and a memo saying why:
curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{"direction": "in", "amount": "50000.00", "idempotency_key": "open-2026-10", "memo": "opening balance of the main till"}' \ http://localhost:8080/api/v1/sacco/cash-adjustmentsGET /api/v1/sacco/treasury shows the cash on hand and the fees earned.
Products
Section titled “Products”A product sets the terms a loan is approved against. Only the owner or an admin, signed in, defines them.
curl -X POST -H "Authorization: Bearer $ADMIN_SESSION" -H "X-Zirzir-Project: $PROJECT" \ -H "X-Zirzir-Environment: live" -H "Content-Type: application/json" \ -d '{"name": "Biashara", "rate_bps": 1800, "method": "declining", "frequency": "monthly", "min_amount_minor": 100000, "max_amount_minor": 50000000, "min_term": 3, "max_term": 24, "savings_multiple_bps": 30000, "min_guarantors": 1, "guarantee_coverage_bps": 5000, "fee_bps": 100, "penalty_bps": 500, "grace_days": 7}' \ http://localhost:8080/api/v1/sacco/loan-products| Field | Meaning |
|---|---|
rate_bps | Interest a year in basis points (1800 = 18%). |
method | flat: interest on the original amount every period. declining: interest on what is still owed, with equal installments. |
frequency | weekly, biweekly or monthly installments. |
min_amount_minor, max_amount_minor, min_term, max_term | Limits on the amount and the number of installments. |
savings_multiple_bps | What a member owes (open loans and applications included) may be at most this multiple of their own savings: available savings less what they owe, so loan money paid into savings does not raise the limit (30000 = 3x). |
min_guarantors, guarantee_coverage_bps | How many guarantors (at most 10), and how much of the loan their pledges must cover together (5000 = half). A guarantor’s savings already pledged to loans awaiting approval count as taken. |
fee_bps | A processing fee, taken from the disbursement. |
penalty_bps, grace_days | A one-time penalty on an installment still unpaid this many days after it fell due, as a share of what is unpaid. |
Members borrowing need the SACCO’s min_share_capital_minor in shares. POST .../loan-products/$ID/retire stops new applications; existing loans keep their terms.
Applying and approval
Section titled “Applying and approval”curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{"member_id": "mem_…", "product_id": "lpr_…", "amount": "12000.00", "term": 12, "purpose": "stock for the shop", "idempotency_key": "loan-77", "guarantors": [{"member_id": "mem_…", "amount": "6000.00"}]}' \ http://localhost:8080/api/v1/sacco/loansThe application is checked against the product (amount, term, savings multiple, share capital, guarantors with enough savings) and answered with 202 and status applied: every loan waits for a checker. Once approved:
- Each guarantor’s pledge is held on their savings: it stays theirs, but they cannot withdraw it, and they cannot leave, while the loan is open.
- The loan is paid into the member’s savings, less the fee, and its schedule starts (the first installment one period after disbursement).
If a guarantee cannot be held (the guarantor spent the savings meanwhile), the loan failed with the reason and nothing is held. GET /api/v1/sacco/loans/$LOAN shows the schedule, what is outstanding, the payoff, days_past_due and the guarantors.
Interest and penalties
Section titled “Interest and penalties”Interest is charged as each installment falls due, in the SACCO’s time zone. On a declining loan it is charged on the principal actually owed at that moment, so paying ahead lowers it. Installments still unpaid after the grace days are penalised once. A background job charges what fell due; repayments also bring it up to date first.
Repaying
Section titled “Repaying”curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{"from": "cash", "amount": "1120.00", "idempotency_key": "rep-77-1"}' \ http://localhost:8080/api/v1/sacco/loans/$LOAN/repaymentsfromiscashorsavings. Each repayment pays, installment by installment from the oldest, the penalty, then the interest due, then the principal due; anything more pays principal ahead.- More than the payoff is refused. When everything is paid the loan is
closed, interest not yet due is never charged, and the guarantees are released. - A loan’s movements are booked one at a time: while another is being booked (during a ledger outage, say), a repayment answers
503and the background job books it right after. - By mobile money: create the payment with
"metadata": {"sacco_member_id": "mem_…", "sacco_purpose": "loan_repayment", "sacco_loan_id": "loan_…"}. A payment beyond the payoff closes the loan and the rest goes to the member’s savings.
Writing off
Section titled “Writing off”curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{"idempotency_key": "wo-77", "memo": "borrower unreachable for 120 days"}' \ http://localhost:8080/api/v1/sacco/loans/$LOAN/write-offA write-off needs a checker and a memo. When approved, the guarantees are called: what is owed is taken from the guarantors’ held savings, in the order they were listed, up to each pledge; the rest is booked as a loan loss. The loan is written_off. Only savings actually held count: a write-off never books a guarantee that was not taken. Its installments keep showing what was unpaid when it was written off; the ledger shows where that went.
Portfolio at risk
Section titled “Portfolio at risk”GET /api/v1/sacco/portfolio gives the active loans, the principal outstanding, the charges due, and PAR30 / PAR90: the principal of loans more than 30 / 90 days past due, in amount and as a share of the portfolio (par30_bps).
Dividends
Section titled “Dividends”At the end of a year the SACCO shares its surplus: a rate on each member’s share capital, and a rebate on the loan interest they paid.
curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{"label": "2026", "period_start": "2026-01-01", "period_end": "2026-12-31", "share_rate_bps": 1200, "interest_rebate_bps": 1000, "paid_from": "interest_income", "idempotency_key": "dividend-2026"}' \ http://localhost:8080/api/v1/sacco/dividends- The answer (
202, statusdeclared) lists every line: each active or suspended member’s shares when declared, the loan interest they paid in the period (local dates), and what they get. Nothing is paid yet; the amounts the checker approves are the ones paid. - It is refused if it comes to more than the income account it is paid from (
interest_incomeorfee_income) holds beyond what other dividends still have to pay from it; if a dividend with the samelabelexists; or if it rebates interest for a period another dividend already rebates. - A checker approves it; then each member’s line is paid into their savings, once each. The dividend is
paidwhen every line is; a line that cannot be paid (the member is leaving, the income fell short) isfailedand the dividendpaid_with_exceptions, withfailed_minor. Fix the cause, thenPOST /api/v1/sacco/dividends/$DIVIDEND/retrypays the failed lines.
Statements and reports
Section titled “Statements and reports”# A member's statement: savings, shares and loan accountscurl -H "X-API-Key: $KEY" \ "http://localhost:8080/api/v1/sacco/members/$MEMBER/statement?from=2026-01-01&to=2026-12-31"
# The SACCO at a glancecurl -H "X-API-Key: $KEY" http://localhost:8080/api/v1/sacco/summary- A statement gives, for each of the member’s accounts, the opening balance, every movement in the period (local dates) with what it was and the balance after it, and the closing balance.
- The summary gives members by status, the balance of every account kind, the loan portfolio (PAR30 / PAR90), and an integrity check of the books:
total_debit_balances_minor(cash, loans, provider balances, losses) equalstotal_credit_balances_minor(savings, shares, income, your own balance) across all the project’s accounts,balanced: true.
Leaving
Section titled “Leaving”curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{"idempotency_key": "exit-77", "memo": "relocating"}' \ http://localhost:8080/api/v1/sacco/members/$MEMBER/exitAn exit always needs a checker, and is refused while the member has a loan or a guarantee that is not over. Until it is decided the member is exiting: nothing moves in or out. Once approved, their shares are returned to savings and everything they hold at that moment is paid out in cash, in one booking, and the member is exited. The entry records what was paid. If the tills cannot cover it, the exit fails, the member is active again, and you can request it once there is cash. A member whose savings are held (a loan guarantee) cannot leave until it is released.
In the dashboard
Section titled “In the dashboard”SACCO in the dashboard is where staff work, signed in with their own role:
- Overview: members by status, the books, loan portfolio and PAR, the tills, and counting cash in or out.
- Members: join, deposit, withdraw, buy shares, suspend or reactivate, each member’s balances, loans and entries.
- Loans: each loan’s schedule, payoff, guarantors, and recording repayments.
- Approvals: what waits for a checker, with Approve and Reject (nobody is offered their own request; they can cancel it).
- Dividends: declaring, each member’s line, and retrying failed payouts.
Settings and loan products are changed by the owner or an admin, as above.
From the SDKs
Section titled “From the SDKs”Your apps use the SACCO through zirzir.sacco in the TypeScript and Python SDKs, in server mode with an API key. Amounts are minor units, and every movement needs an idempotency key. A movement either posts at once or waits for a checker:
const zirzir = new Zirzir({ baseUrl: 'http://localhost:8080', apiKey: process.env.ZIRZIR_KEY! });
const member = await zirzir.sacco.join({ customerId: 'cus_…', payEntranceFee: 'cash' });const result = await zirzir.sacco.withdraw(member.id, { amountMinor: 120000, idempotencyKey: 'wd-1001' });if (result.awaitingApproval) { console.log(`waiting for a checker: ${result.approvalId}`);}
const loan = await zirzir.sacco.applyForLoan({ memberId: member.id, productId: 'lpr_…', amountMinor: 1200000, term: 12, idempotencyKey: 'loan-77', guarantors: [{ memberId: 'mem_…', amountMinor: 600000 }],});zirzir = Zirzir(ServerConfig(base_url="http://localhost:8080", api_key=KEY))
entry = await zirzir.sacco.deposit(member_id, amount_minor=200000, idempotency_key="dep-1001")pending = await zirzir.sacco.approvals.list(status="pending")summary = await zirzir.sacco.summary()API keys may be the maker, so the SDKs do not approve or reject, or change settings and loan products: those stay with signed-in staff. An app can cancel a request it made with approvals.cancel.
How it is booked
Section titled “How it is booked”| Movement | Debit | Credit |
|---|---|---|
| Cash deposit | cash on hand | member savings |
| Mobile-money deposit | merchant available | member savings |
| Cash withdrawal | member savings | cash on hand |
| Shares | cash on hand / member savings / merchant available | member shares |
| Entrance fee | cash on hand | fee income |
| Cash counted in / out | cash on hand / adjustments | adjustments / cash on hand |
| Exit | member shares, then member savings | member savings, then cash on hand |
| Loan disbursement (and fee) | loan principal (member savings) | member savings (fee income) |
| Interest / penalty falls due | interest receivable | interest income / fee income |
| Loan repayment | cash on hand / member savings / merchant available | interest receivable, loan principal |
| Guarantee | guarantor savings (held) | guarantee clearing |
| Write-off | guarantee clearing, then loan losses | interest receivable, loan principal |
| Dividend | interest income / fee income | member savings |
Member, loan and income accounts, and the tills, move only through the SACCO API: the generic ledger transfer and hold endpoints refuse them, so no request can skip the approvals.
Trust and limits
Section titled “Trust and limits”- Admins are trusted. The owner and admins can change staff roles and the withdrawal threshold, so they can weaken the controls; changes are recorded, but not themselves approved. Keep admins few, and give tellers
makerand supervisorschecker. - One server per database. Movements for a member are serialised inside the zirzir process. Run one zirzir instance per database (the SQLite default); the ledger’s balance limits still hold either way.
- Mobile-money and cash are separate. Money paid in by mobile money stays with the provider; withdrawals and exits pay cash from the tills. Move money from the provider to the tills by booking the payout and counting the cash in.