Payments API
Integration quick start
1. Portal setup (one time)
Sign in at /portal/login. Only users with the Portal admin role can change integration settings; Team member users can view activity but cannot edit developers, team, or financial settings.
In the merchant portal (/portal/developers — Portal admin only):
- API clients — create a client; store
client_idandclient_secretsecurely (secret shown once). - API credentials — create an API client, then set that client's HTTPS webhook URL; copy the webhook secret (shown once). Optional API IP allowlist is on the API security tab.
- Confirm country and API product flags on
/portal/settings(read-only profile; Portal admin only; country drives mobile networks, currency, balance defaults, and your Testing amount limit). - Check the portal shell badge: Testing or Live. While Testing, keep collection and fixed checkout amounts at or below the country limit; do not use open (flexible) amount checkout.
Never commit secrets to source control or embed them in mobile apps.
Testing mode
New merchants start in Testing mode (visible as a badge in the merchant portal). Self-registration at /register sets the account to Approved with API access on, so you can create API keys immediately and call the production API on live processors. MojoPay still limits exposure until staff switch you to Go Live.
Sandbox API products (on at signup): Hosted checkout, Card on pay links, Payouts, and Metadata. Collections, Subscriptions, and Sub-accounts stay off until MojoPay staff enable them.
| Rule | Detail |
|---|---|
| Max amount (per country) | Caps money-in only: POST /v1/collections, fixed-amount POST /v1/checkout/sessions, and the amount a customer enters on hosted /pay/{id}. Limit is configured per country by MojoPay. |
| No open / flexible checkout | amount_mode: open is rejected while Testing. Use amount_mode: fixed with an amount at or below the limit. Portal pay-link create hides the open option. |
| Errors | Over-limit or open-amount attempts return 400 with error.code invalid_parameter (field amount or amount_mode). |
| Not capped | Payouts, verifications, mandates/debits, and balance/metadata reads are not subject to the testing amount cap. |
| API tester | /api-tester/ calls the same live API — Testing rules apply there too. |
| Portal help | Mojo Ai in /portal can explain Testing/Go Live and help debug Omni API payloads against this guide. |
After Go Live, the amount cap and open-amount restriction no longer apply.
Portal team roles
| Role | Can do |
|---|---|
Portal admin (owner) |
API clients, webhooks, IP allowlist, team members, pay links, payouts, settlement changes |
Team member (member) |
View dashboard, collections, transactions, payouts, pay links, settlement — no settings or write actions |
The portal UI hides actions your role cannot perform; the server returns 403 if a write is attempted without permission.
Collection fees
When platform collection fees apply to your merchant account, MojoPay staff also set a fee mode for your account:
| Mode | What you send as amount |
Customer / upstream pays | You settle (wallet credit) |
|---|---|---|---|
on_top (default) |
What you want to receive | amount + fee (amount_payable) |
amount |
absorb |
What the customer pays (invoice) | amount (amount_payable equals amount) |
amount − fee (merchant_settlement_amount) |
- Responses include
fee_amount,fee_mode,amount_payable, andmerchant_settlement_amount. - Fee percentage/cap and fee mode are configured by MojoPay staff (platform defaults + optional per-merchant overrides for %/cap; fee mode is per merchant), not in your API request body.
- Hosted checkout sessions expire
expires_at(currently 24 hours after create); create a new session if the link expires.
Payout fees
Optional per-merchant payout fees may apply when you create a payout:
- Send
amountas what the recipient should receive. - When a payout fee is configured, your payout wallet must cover
amount+ fee. The wallet is debited for both; the recipient still receives the fullamount. - Responses include
fee_amountandamount_debited(amount+ fee). When no payout fee applies,fee_amountis"0.00"andamount_debitedequalsamount. - Payout fee rules are set by MojoPay staff per merchant (not in the request body). Both percentage and cap must be greater than zero; otherwise
fee_amountis"0.00". There is no platform-wide payout fee default. - The same wallet rule applies to portal payouts, CSV import, payout groups, and settlement withdrawals to bank or mobile money. Auto-settlement that empties the payout pot to bank/MoMo reduces the recipient amount so amount + fee still fit. Interval half-hour settlement only moves released collection funds into the payout pot and does not charge an instant settlement fee.
Settlement and wallets (portal)
Successful collections credit your collection wallet. MojoPay uses T+1 settlement:
- Actual collection wallet — total succeeded funds on record.
- Available collection wallet — funds released after the next-day settlement window (default 08:00 in the platform settlement timezone). These can be moved without an instant settlement fee.
- Held funds — still within the T+1 window; moving them early via Move funds on
/portal/settlementincurs an instant settlement fee on the held portion (percentage capped; shown in the portal before you confirm).
Auto-settlement (when approved by MojoPay) moves released collection funds on schedule without an instant settlement fee. Daily T+1 and interval auto-settlement may then pay out to bank or mobile money (a payout fee can apply if staff configured one). Merchants on interval release wait a short hold (default 30 minutes) then a half-hour job settles to their approved destination (payout pot, bank, or mobile money) — still no instant settlement fee.
Payouts (API or portal) draw from the payout wallet. Move funds from collection → payout first, or rely on auto-settlement to fund the payout wallet.
GET /v1/balance returns collection_wallet (available), collection_wallet_actual, collection_wallet_held, and payout_wallet — see Metadata and balance.
2. Choose your integration path
| Your goal | API | Confirm success |
|---|---|---|
| Customer pays on a hosted page (redirect) | POST /v1/checkout/sessions → redirect to url |
Webhook checkout_session.succeeded (+ optional GET /v1/transactions/verify/{merchant_reference}) |
| Server-initiated mobile-money collection (you already have wallet + network) | POST /v1/collections |
Webhook collection.succeeded |
| Payout to mobile money or bank | POST /v1/payouts |
Webhook payout.succeeded |
| Name match before payout | POST /v1/verifications/msisdn or bank_accounts |
200 with status: verified |
Recommendation: integrate hosted checkout first — fewest fields at create time, customer enters wallet details on MojoPay, and you reconcile with one merchant_reference.
3. Authenticate (every ~20 minutes)
curl -sS -X POST "https://omni.mojo-pay.com/v1/oauth/token" \
-H "Content-Type: application/json" \
-d '{"client_id":"YOUR_CLIENT_ID","client_secret":"YOUR_CLIENT_SECRET"}'
Response includes access_token (opaque, 64 characters) and expires_in (default 1200 seconds). Cache the token server-side and refresh before expiry.
4. Hosted checkout (minimal happy path)
Generate a fresh UUID for each Idempotency-Key.
# 1) Token
TOKEN=$(curl -sS -X POST "https://omni.mojo-pay.com/v1/oauth/token" \
-H "Content-Type: application/json" \
-d '{"client_id":"YOUR_CLIENT_ID","client_secret":"YOUR_CLIENT_SECRET"}' \
| jq -r .access_token)
# 2) Create session
curl -sS -X POST "https://omni.mojo-pay.com/v1/checkout/sessions" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"amount_mode": "fixed",
"currency": "GHS",
"amount": "1.00",
"merchant_reference": "ORD-2026-001",
"description": "Test order",
"success_url": "https://merchant.example.com/paid",
"cancel_url": "https://merchant.example.com/cancelled"
}'
Redirect the customer to the returned url. Do not mark the order paid on redirect alone — wait for a checkout_session.succeeded webhook (or poll GET /v1/transactions/verify/ORD-2026-001 from your server).
5. Webhook handler checklist
- Read the raw request body (do not re-serialize JSON before verify).
- Verify
MojoPay-Signature(see below). - Deduplicate on event
id(EVT_…) — delivery is at-least-once. - Update your order only on terminal events (
succeeded/failed). - Respond
200(or204) quickly; do heavy work asynchronously.
6. Verify webhook signatures
Header format: MojoPay-Signature: t=<unix_seconds>,v1=<hex>
Signed payload: {t}.{raw_body} with HMAC-SHA256 and your webhook secret.
Reject timestamps outside ±300 seconds (5 minutes) of your server clock.
Simulates what MojoPay sends to your webhook URL. Replace the URL, secret, and body as needed.
WEBHOOK_URL="https://merchant.example.com/webhooks/mojopay"
WEBHOOK_SECRET="whsec_from_portal"
TIMESTAMP=$(date +%s)
BODY='{"id":"EVT_TEST001ABCDEFGHIJKLMNOPQR","type":"checkout_session.succeeded","api_version":"2026-04-15","created":"2026-04-15T10:05:00Z","data":{"object":{"id":"CS_TEST001ABCDEF","object":"checkout_session","status":"succeeded","currency":"GHS","amount":"1.00","merchant_reference":"ORD-TEST-001","payment_rail":"mobile_money","provider":"MTN"}}}'
SIGNATURE=$(printf '%s' "${TIMESTAMP}.${BODY}" \
| openssl dgst -sha256 -hmac "${WEBHOOK_SECRET}" \
| awk '{print $NF}')
curl -sS -X POST "${WEBHOOK_URL}" \
-H "Content-Type: application/json" \
-H "MojoPay-Signature: t=${TIMESTAMP},v1=${SIGNATURE}" \
-d "${BODY}"
$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_MOJOPAY_SIGNATURE'] ?? '';
if (! preg_match('/^t=(\d+),v1=([a-f0-9]{64})$/', $header, $m)) {
http_response_code(400);
exit;
}
[$full, $timestamp, $signature] = $m;
if (abs(time() - (int) $timestamp) > 300) {
http_response_code(400);
exit;
}
$expected = hash_hmac('sha256', $timestamp.'.'.$payload, getenv('MOJOPAY_WEBHOOK_SECRET'));
if (! hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}
$event = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);
// Dedupe on $event['id'], then switch on $event['type'].
http_response_code(200);
import crypto from 'node:crypto';
export function verifyMojoPayWebhook(rawBody, signatureHeader, secret, toleranceSec = 300) {
const match = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(signatureHeader ?? '');
if (!match) return false;
const timestamp = Number(match[1]);
const signature = match[2];
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSec) {
return false;
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(signature, 'hex'),
);
}
Your handler should return 200 when the signature verifies. Use the exact BODY string when computing HMAC — any whitespace change invalidates the signature.
7. Idempotency and safe retries
| Situation | What to do |
|---|---|
| Network timeout after POST | Retry with the same Idempotency-Key and same JSON body |
| New payment attempt | New Idempotency-Key (e.g. UUID v4) |
| Same key, different body | API returns 409 idempotency_key_mismatch |
| Same key while the first request is still running | API returns 409 idempotency_key_in_progress — wait briefly and retry |
502 gateway_error or 500 on a write |
Check status first (GET /v1/transactions/verify/{merchant_reference}, GET /v1/payouts/{merchant_reference}, or GET /v1/mandates?merchant_reference=). If the resource exists, follow its status; if it returns 404, retry with the same key and body |
Retention: only successful (2xx) responses are stored against an Idempotency-Key; they are kept with no expiry and replayed verbatim for the same key and body. Failed (4xx / 5xx) responses are not stored, so the same key can be retried after you fix the cause. Keys are scoped per merchant, per sub-account, and per endpoint.
Use a unique merchant_reference per business transaction (your order id). Max 255 characters; stick to letters, numbers, and - / _ for easiest debugging.
8. Integration checklist
- OAuth token cached and refreshed before expiry
-
Idempotency-Keyon every money-moving POST - While Testing: amounts ≤ country limit; no
amount_mode: open - Webhook signature verified on raw body
- Event
iddeduplication store (DB or cache) - Order fulfillment only after
succeededwebhook or verify -
GET /v1/metadata/mobile_providersused for network codes (don’t hardcode) - If using bank VAs: Static VA / bank transfer enabled; show payers
account_number+account_name+bank_name; treat dynamicexpires_ataccounts as one-order only - Secrets in environment variables, not in app code or URLs
Tools: OpenAPI spec · YAML download · Sample payloads JSON · API tester