Skip to content

Customers, KYC & Wallets

Customers are the people and businesses your project serves. Zirzir keeps their profile, verifies them in KYC tiers, and can hold money for them in wallets: stored value booked in the ledger, with per-tier limits enforced on every movement.

All endpoints use your API key. Each environment (test/live) has its own customers.

Terminal window
curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"full_name": "Abebe Kebede", "phone": "+251 911 22 33 44",
"date_of_birth": "1990-05-17", "external_ref": "crm-1"}' \
http://localhost:8080/api/v1/customers
  • kind is individual (default) or business.
  • Phone numbers are normalised (+251911223344) and unique per environment, as is external_ref (your own id).
  • GET /api/v1/customers?q=abebe&status=active&kyc_status=verified searches by name, phone, email or external reference.
  • PATCH /api/v1/customers/:id updates fields. Changing the name or date of birth of a tier 2+ customer sends them back to review at tier 1: their documents no longer prove the new identity.
  • POST .../freeze and .../unfreeze stop and restart all wallet movements. POST .../close closes a customer whose wallets are empty.
  • GET .../events is the customer’s audit trail: every change, review, decision and document-number reveal.
TierIndividualBusinessUnlocks
0——Nothing (no wallet)
1Name and phoneName and phoneA wallet, within tier 1 limits
2An accepted, unexpired identity document (national ID, kebele ID, passport, driving licence, residence permit)An accepted business licenceTier 2 limits
3Tier 2 plus an accepted proof of addressTier 2 plus an accepted TIN certificateTier 3 limits

You review documents and decide tiers; zirzir refuses a tier whose requirements are not met.

Terminal window
# Submit a document (the number is encrypted at rest; only the last 4 are shown)
curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"doc_type": "national_id", "number": "ET-0001-2345-6789",
"issuing_country": "ET", "expires_on": "2030-01-01",
"file_url": "https://files.example.com/id.jpg", "file_sha256": "<sha-256 of the file>"}' \
http://localhost:8080/api/v1/customers/$CUSTOMER/documents
# Accept or reject it (a rejection needs a note)
curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"decision": "accept"}' \
http://localhost:8080/api/v1/customers/$CUSTOMER/documents/$DOC/review
# What is missing for tier 2?
curl -H "X-API-Key: $KEY" "http://localhost:8080/api/v1/customers/$CUSTOMER/kyc/requirements?tier=2"
# Approve at tier 2 (or {"decision": "reject", "note": "…"})
curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"decision": "approve", "tier": 2, "note": "ID checked in branch"}' \
http://localhost:8080/api/v1/customers/$CUSTOMER/kyc

Document files stay in your own storage: send their file_url (https) and file_sha256 so the record points at exactly the file you reviewed. Set ZIRZIR_ENCRYPTION_KEY so document numbers are encrypted at rest.

The API never returns a full document number, only its last 4 characters. A reviewer signed in to the dashboard can reveal one with GET /api/projects/$PROJECT/customers/$CUSTOMER/documents/$DOC/number?environment=live (a user session, not an API key); every reveal is recorded with the user in the customer’s events and the audit log.

If a verified customer’s name or date of birth changes, their accepted documents are marked superseded and no longer count: collect and review new ones before granting tier 2 or 3 again.

Set limits per tier and currency. A limit you do not set does not apply, so configure the limits your regulator requires before going live.

Terminal window
curl -X PUT -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"tier": 1, "currency": "ETB", "max_balance_minor": 500000,
"max_single_minor": 200000, "max_daily_out_minor": 500000}' \
http://localhost:8080/api/v1/kyc/limits
LimitApplies to
max_balance_minorA wallet’s balance after any credit (deposit, top-up, incoming transfer)
max_single_minorEach withdrawal or outgoing transfer
max_daily_out_minorThe sum of withdrawals and outgoing transfers per UTC day
Terminal window
# Open an ETB wallet (tier 1 or above, active customers only)
curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"currency": "ETB"}' http://localhost:8080/api/v1/customers/$CUSTOMER/wallets
# Cash-in: from your balance into the wallet
curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"currency": "ETB", "amount": "300.00", "idempotency_key": "cashin-77"}' \
http://localhost:8080/api/v1/customers/$CUSTOMER/wallets/deposit
# Cash-out or purchase: from the wallet into your balance (same body)
curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"currency": "ETB", "amount": "50.00", "idempotency_key": "cashout-12"}' \
http://localhost:8080/api/v1/customers/$CUSTOMER/wallets/withdraw
# Wallet to wallet
curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"from_customer_id": "cus_a", "to_customer_id": "cus_b", "currency": "ETB",
"amount": "80.00", "idempotency_key": "p2p-9"}' \
http://localhost:8080/api/v1/wallet-transfers
  • Amounts are amount (an exact decimal string) or amount_minor.
  • Every movement is idempotent: the same idempotency_key returns the original transfer with "replayed": true, even if a limit would now refuse a new one.
  • Movements for one customer are processed one at a time, so concurrent requests cannot together break a limit.
  • Wallet money only moves through these endpoints. The generic ledger transfer and hold endpoints refuse wallet accounts, so limits cannot be bypassed.
  • GET /api/v1/customers/$CUSTOMER/wallets shows each wallet’s balance and available.

Let a customer fund their wallet with any payment provider: create the payment with metadata.wallet_customer_id.

Terminal window
curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"provider": "telebirr", "reference": "TOPUP-1", "amount": 50.00, "currency": "ETB",
"customer_phone": "0911223344", "metadata": {"wallet_customer_id": "cus_a"}}' \
http://localhost:8080/api/v1/payments

The customer is checked when the payment is created (a top-up cannot also split to a subaccount). When the payment completes and is booked, the amount moves from your balance into their wallet, exactly once. If crediting it would break a limit (or the customer was frozen in the meantime), the top-up is held as blocked and recorded in the customer’s events; fix the cause and retry it, or refund the payment.

Refunding a top-up payment:

  • If the top-up has not reached the wallet yet (pending or blocked), the refund cancels it (status refunded).
  • If it was already credited, the refund is refused until you reverse it with POST /api/v1/wallet-topups/$PAYMENT/reverse, which takes the amount back out of the wallet (it must still be there). Otherwise the customer would keep the value while the payer gets their money back.

A customer with pending or blocked top-ups cannot be closed.

Terminal window
curl -H "X-API-Key: $KEY" "http://localhost:8080/api/v1/wallet-topups?status=blocked"
curl -X POST -H "X-API-Key: $KEY" http://localhost:8080/api/v1/wallet-topups/$PAYMENT/retry