Wallet funding — collections & payouts
Every merchant has a GHS wallet: the cedi balance a buy trade draws from, and where a sell trade's proceeds land. You fund and defund it two ways — top up / withdraw in the dashboard (MoMo, card, or bank), or move cedis over these collection and payout APIs. Either way it feeds the same balance.
StarPay moves cedis over mobile money and bank rails behind one API. You call the
same endpoints regardless of which provider (Hubtel, Nsano, or Korba) actually
serves the request — StarPay picks the primary for the channel and fails over to
the next provider on a timeout or outage. Amounts are always integer minor
units (pesewas): GHS 110.50 is 11050.
Every money movement is asynchronous. An initiate call returns pending once the
rail accepts it; the final state (success / failed) arrives on a provider
callback. Poll the resource or listen for the webhook — never
assume success from the initiate response.
Collect over mobile money
POST /api/v1/charges pulls funds from a customer's wallet. Requires the
collections:write scope and an Idempotency-Key.
curl -X POST https://api-dev.starpay.app/api/v1/charges \
-H "Authorization: Bearer sk_test_..." \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"amount": 11050,
"currency": "GHS",
"channel": "momo",
"msisdn": "0240000000",
"network": "MTN"
}'
{
"status": true,
"message": "Charge initiated",
"data": {
"id": "ch_9b2c7c1e8d4a05f3",
"status": "pending_provider",
"amount": 11050,
"currency": "GHS",
"fees": 110,
"amount_settled": 10940,
"channel": "momo",
"provider": "nsano"
}
}
network is optional — StarPay infers it from the number prefix (MTN, Telecel,
AirtelTigo) and only needs an override for a ported number. Fetch the latest
state with GET /api/v1/charges/{id}.
Pay out over mobile money or bank
POST /api/v1/transfers pushes funds out. Money-out is high-risk, so it requires
the transfers:write scope, an Idempotency-Key, and an
HMAC signature. The amount plus fee is reserved from your
balance immediately; the payout resolves to its terminal state via callback.
Mobile-money payout:
{ "amount": 5000, "currency": "GHS", "channel": "momo", "msisdn": "0540000000" }
Bank payout — bank_code is the institution short name (e.g. GCB, ECBK):
{
"amount": 20000,
"currency": "GHS",
"channel": "bank",
"account_number": "1234567890",
"bank_code": "GCB",
"bank_account_name": "Ama Owusu"
}
Verify a recipient first (name enquiry)
Before paying a wallet or account, confirm the holder's name. These are read-only
lookups (the read scope) — nothing moves.
POST /api/v1/resolve/momo-name:
{ "msisdn": "0240000000" }
POST /api/v1/resolve/bank-name:
{ "account_number": "1234567890", "bank_code": "GCB" }
{
"status": true,
"message": "Name resolved",
"data": { "name": "Ama Owusu", "instrument": "233240000000", "channel": "momo", "provider": "nsano" }
}
A wallet/account that doesn't resolve returns 404 not_found; a provider outage
returns 502 provider_unavailable.
Settlement & callbacks
For a collection, no money is booked to your wallet until the rail confirms —
the ledger posting happens at settlement, not initiation. For a payout, funds
are held at initiation and released (or refunded on failure) when the rail
resolves. The provider posts its result to the internal webhook receiver, which
finalizes the transaction and emits your merchant webhook
(charge.success, transfer.success, transfer.failed, …).
In the sandbox, the Simulator drives deterministic outcomes from
magic MSISDNs (0240000000 → success, 0240000001 → insufficient funds /
"no match" for name enquiry, 0240000002 → timeout-then-success, proving
failover). Start there, then point at a real provider sandbox by configuration —
no code changes.