Collections & payouts

StarPay moves cedis over mobile money and bank rails behind one API. You call the same endpoints regardless of which provider (Hubtel, Nsano, Korba, MojoPay or OGateway) actually serves the request — StarPay routes each payment method to exactly one provider, and never re-sends a payment to a second one. If a response is lost, the payment is held and reconciled rather than retried elsewhere, so a lost response can never become a double charge. 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_7KpQ2mXnVt4bZr",
    "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. It is worth sending for a ported number all the same: routing is network-aware, so the network we resolve decides which rail serves the charge, and a prefix that no longer matches the subscriber's network can send it to a rail that cannot reach them. Fetch the latest state with GET /api/v1/charges/{id}.

Stablecoin collections

Approved merchants can initiate a channel="crypto" GHS charge with asset (USDT or USDC) and a supported blockchain network. Hosted checkout, public payment pages and self-service funding use the same pricing service.

The base is Binance's public P2P SELL quote for the specific asset against GHS. StarPay deducts the approved platform markup: a flat GHS amount per coin or a percentage of that market price. At GHS 12.00 per coin, a GHS 0.20 flat markup gives GHS 11.80; a 1% markup gives GHS 11.88. The requested stablecoin amount covers the GHS charge and is rounded up to the custody rail's precision.

metadata.crypto returns the deposit address, exact payable amount and expiry, plus decimal-string market_rate, payment_rate, markup_type, markup_value, rate_currency, rate_source and rate_fetched_at. Prices are cached for up to 60 seconds; the issued payment quote stays fixed for 15 minutes. Unavailable market prices refuse new quotes while already-issued quotes remain unchanged. Never calculate a new payable amount on the client: send crypto_amount_display on the stated network before expires_at.

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"
}

If StarPay has switched disbursements off for your account, a transfer is refused before anything is reserved, in test and live mode alike:

{
  "status": false,
  "error": {
    "code": "permission_denied",
    "message": "Disbursements are not enabled for this account. Contact StarPay support to request access.",
    "details": { "requirement": "disbursements" }
  }
}

Nothing was created, so once support has switched disbursements back on you can retry the same request with the same Idempotency-Key.

Your own metadata

Both POST /api/v1/charges and POST /api/v1/transfers accept an optional metadata object of your own labels, stored on the transaction and returned with it. A few keys are reserved for StarPay's own use, and a request that sends one is refused with 400 validation_error, before anything is created: campaign_disbursement, dispatch_rail, payout_batch, payout_item, refund_of_held_funds, selfservice_order, selfservice_role, susu_member, susu_payout and wallet_funding. payout_batch and payout_item were added in September 2026, with StarPay's payroll payouts, and the campaign_, selfservice_ and susu_ names at the same time: StarPay's own campaigns, self-service orders and susu groups use them to find the payment they settle. wallet_funding was added in October 2026: it marks a top-up of your own StarPay wallet from your own mobile money number. If your integration used any of these names as its own label, rename it (for example my_batch).

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, so you can assert how your integration handles a rail that never answers, and 0240000004 → a timeout after the request was sent, which stays pending and never settles). Start there, then point at a real provider sandbox by configuration — no code changes.