Skip to content

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.

The project’s owner adds staff: people who signed up to zirzir, each with a role.

Terminal window
curl -X POST -H "Authorization: Bearer $OWNER_SESSION" -H "Content-Type: application/json" \
-d '{"email": "almaz@example.com", "role": "checker"}' \
http://localhost:8080/api/projects/$PROJECT/staff
RoleCan
makerOperate: register members, take deposits, request withdrawals
checkerEverything a maker can, and approve or reject what others requested
adminEverything, 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:

Terminal window
curl -H "Authorization: Bearer $SESSION" -H "X-Zirzir-Project: $PROJECT" -H "X-Zirzir-Environment: live" \
http://localhost:8080/api/v1/approvals?status=pending

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:

Terminal window
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.
  • reject needs a note. The requester (or an admin) can cancel a 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.

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.

Terminal window
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
FieldMeaning
currencyThe SACCO’s books are in one currency. It cannot change once money has moved.
min_kyc_tierThe KYC tier a customer needs to join (default 1).
entrance_fee_minorCharged in cash when a member joins (default 0).
min_share_capital_minorThe share capital members are expected to hold.
utc_offset_minutesThe 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_minorA 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 are customers who joined:

Terminal window
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, …; send member_no to 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/$MEMBER shows shares, savings and savings_available (savings not held by a loan guarantee).
  • POST .../suspend and .../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.

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.

Terminal window
# Cash deposit into savings
curl -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 savings
curl -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/shares

Each 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.

Create a payment with the member in its metadata; when it completes, it is credited to their savings (or shares):

Terminal window
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/payments
  • sacco_purpose is savings (default) or shares. 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.

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:

Terminal window
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-adjustments

GET /api/v1/sacco/treasury shows the cash on hand and the fees earned.

A product sets the terms a loan is approved against. Only the owner or an admin, signed in, defines them.

Terminal window
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
FieldMeaning
rate_bpsInterest a year in basis points (1800 = 18%).
methodflat: interest on the original amount every period. declining: interest on what is still owed, with equal installments.
frequencyweekly, biweekly or monthly installments.
min_amount_minor, max_amount_minor, min_term, max_termLimits on the amount and the number of installments.
savings_multiple_bpsWhat 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_bpsHow 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_bpsA processing fee, taken from the disbursement.
penalty_bps, grace_daysA 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.

Terminal window
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/loans

The 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:

  1. 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.
  2. 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 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.

Terminal window
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/repayments
  • from is cash or savings. 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 503 and 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.
Terminal window
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-off

A 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.

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

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.

Terminal window
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, status declared) 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_income or fee_income) holds beyond what other dividends still have to pay from it; if a dividend with the same label exists; 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 paid when every line is; a line that cannot be paid (the member is leaving, the income fell short) is failed and the dividend paid_with_exceptions, with failed_minor. Fix the cause, then POST /api/v1/sacco/dividends/$DIVIDEND/retry pays the failed lines.
Terminal window
# A member's statement: savings, shares and loan accounts
curl -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 glance
curl -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) equals total_credit_balances_minor (savings, shares, income, your own balance) across all the project’s accounts, balanced: true.
Terminal window
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/exit

An 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.

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.

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.

MovementDebitCredit
Cash depositcash on handmember savings
Mobile-money depositmerchant availablemember savings
Cash withdrawalmember savingscash on hand
Sharescash on hand / member savings / merchant availablemember shares
Entrance feecash on handfee income
Cash counted in / outcash on hand / adjustmentsadjustments / cash on hand
Exitmember shares, then member savingsmember savings, then cash on hand
Loan disbursement (and fee)loan principal (member savings)member savings (fee income)
Interest / penalty falls dueinterest receivableinterest income / fee income
Loan repaymentcash on hand / member savings / merchant availableinterest receivable, loan principal
Guaranteeguarantor savings (held)guarantee clearing
Write-offguarantee clearing, then loan lossesinterest receivable, loan principal
Dividendinterest income / fee incomemember 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.

  • 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 maker and supervisors checker.
  • 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.