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.