Reference
The Merchant API lets your application send money (to an OffPay wallet or any Nigerian bank account) and receive money (from an OffPay user, or from anyone paying by card, bank transfer, or USSD) programmatically.
https://offpay.4go.com.ng/api/v12500.00), not kobo — except where noted.There is currently no separate signup flow outside the OffPay app/website — your merchant account is tied to your own OffPay user account.
Every request must include your secret key as a Bearer token:
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxTest keys look like sk_test_xxxxxxxxxxxxxxxxxxxx and behave identically, but are intended for integration testing.
Keep your secret key server-side only. It is shown to you exactly once, at creation time — if you lose it, revoke the key and create a new one.
Unauthenticated or invalid requests return 401 Unauthorized. A suspended or under-review merchant account returns 403 Forbidden on every call until it’s active again.
120 requests per minute, per merchant (not per key, not per IP — all your keys share one limit). Exceeding it returns 429 with:
{ "success": false, "message": "Too many requests. Please slow down." }Errors always look like:
{ "success": false, "message": "Human-readable explanation." }Common HTTP status codes: 400 (bad request — check message), 401 (missing/invalid key), 403 (account not active), 404 (not found), 409 (conflict — e.g. paying an already-paid charge), 429 (rate limited).
/balanceReturns your merchant wallet balance.
{ "success": true, "data": { "balance": 145000.00, "currency": "NGN" } }/wallets/resolve?walletId=... or ?accountNumber=...Look up an OffPay user’s name before paying them — the same way the app itself resolves a recipient before you confirm a transfer.
{ "success": true, "data": { "walletId": "OP-12345", "accountNumber": "9012345678", "accountName": "Ada Obi" } }/paymentsSends money out — to another OffPay wallet, or to any Nigerian bank account.
{
"reference": "your-own-unique-id-001",
"amount": 5000,
"narration": "Vendor payout",
"destination": {
"type": "offpay_wallet",
"walletId": "OP-12345"
}
}destination.type is either:
walletId or accountNumber. Settles instantly.bankCode, accountNumber, and optionally bankName/accountName for your own records. Settles via the usual bank transfer rails (status starts "processing", and your webhook fires once it clears).reference is your own idempotency key — replaying the same reference returns the original result rather than paying twice.
Response:
{
"success": true,
"data": {
"reference": "your-own-unique-id-001",
"status": "success",
"amount": 5000,
"fee": 0,
"destination": { "type": "offpay_wallet", "accountName": "Ada Obi" },
"narration": "Vendor payout",
"createdAt": "2026-09-14T10:00:00Z",
"completedAt": "2026-09-14T10:00:01Z"
}
}/payments/:reference and /payments?status=&limit=&offset=Look up one payment, or list your payment history (most recent first).
This is the other half of the API — creating a request for payment (“charge”) that anyone can pay, whether or not they have an OffPay account.
/charges{
"reference": "order-4471",
"amount": 12000,
"narration": "Order #4471",
"expiresInMinutes": 30
}expiresInMinutes is optional (default 30). reference is your own idempotency key, same as payments.
Response:
{
"success": true,
"data": {
"id": "8f2a1c3e-...",
"reference": "order-4471",
"amount": 12000,
"currency": "NGN",
"status": "pending",
"checkoutUrl": "https://offpay.4go.com.ng/pay/8f2a1c3e-...",
"expiresAt": "2026-09-14T10:30:00Z",
"createdAt": "2026-09-14T10:00:00Z"
}
}Redirect your customer to checkoutUrl (or send it to them — by email, SMS, chat, wherever). On that hosted page they choose how to pay:
Either way, once payment completes: the charge’s status becomes "success", your merchant wallet balance is credited the full charge amount, and (if configured) your webhook fires a charge.success event.
A charge created with a test key skips both of those payment methods — see §8 for how test charges are completed instead.
/charges/:referenceChecks a charge’s status by your own reference.
{ "success": true, "data": { "id": "8f2a1c3e-...", "reference": "order-4471", "status": "success", "amount": 12000, "currency": "NGN", "completedAt": "2026-09-14T10:04:12Z" } }Note: a charge can be paid at most once, and paying an expired or already-paid charge is rejected — create a new charge for a retry rather than reusing one.
Set your webhook URL from Me → Merchant API in the app. Every event is a POST with this shape:
{
"event": "payment.success",
"data": { "...": "..." },
"timestamp": "2026-09-14T10:00:01Z"
}Every event’s data also includes "livemode": true (or false for test-mode activity — see §8).
| Event | Fired when |
|---|---|
payment.success | A POST /payments you made completed |
payment.failed | A POST /payments you made failed (e.g. a bank transfer payout was rejected) |
charge.success | A charge you created was paid — by card, bank transfer, USSD, or an OffPay wallet |
Every webhook request includes an X-OffPay-Signature header — an HMAC-SHA256 of the raw JSON body, using your webhook secret (shown once at creation, and again each time you rotate it from the Merchant API page). Always verify this before trusting a webhook’s contents.
const crypto = require('crypto');
function isValidSignature(rawBody, signatureHeader, webhookSecret) {
const expected = crypto.createHmac('sha256', webhookSecret).update(rawBody).digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}A webhook that doesn’t respond 2xx is retried automatically. Your endpoint should be idempotent — use the reference in the payload to ignore an event you’ve already processed, rather than assuming every delivery is new.
Webhook delivery failures never affect the underlying payment — by the time a webhook fires, the money has already moved (or definitively failed). The webhook is a notification, not part of the transaction itself.
Keys are environment-scoped (sk_live_... / sk_test_...) and fully isolated from each other:
GET /balance, GET /payments, GET /payments/:reference, and GET /charges/:reference are all scoped to whichever key made the request.POST /payments made with a test key debits your test balance and is recorded as an immediate simulated success — it never credits a real OffPay user’s real wallet and never calls a real bank payout./charges/:reference/simulateNot authenticated with your API key — this is called from the hosted checkout page, not your server. The checkoutUrl for a test charge shows a “Simulate successful payment” button instead of real payment options, which calls this for you. There’s no need to call it directly unless you’re automating end-to-end tests.
{ "success": true, "message": "Test payment simulated.", "data": { "id": "8f2a1c3e-...", "reference": "order-4471", "status": "success" } }Webhooks include "livemode": false on every event fired from test activity (and true for live), the same convention Stripe and Paystack use, so your handler can tell them apart without tracking which key created the underlying payment.
Use small amounts for live-key integration testing regardless — the separation above only applies to test keys.
Questions or issues — reach out via Me → Support in the app, or your account contact if you have one.