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.