Conventions
The rules below hold for every endpoint on the API. Read them once and the rest of this reference is mechanical.
| Convention | Value | When | Notes |
|---|---|---|---|
Base URL | https://qoinpay.com/api/v1 | HTTPS only | All paths in this reference are relative to it. |
Authentication | Authorization: Bearer sk_… | Required | Mode-scoped secret key on every request. Server-side only. |
Content type | application/json | On writes | Send a JSON body on POST; UTF-8. |
Amounts | integer minor units | Always | Kobo for NGN, cents for USD. ₦5,000.00 = 500000. Floats are rejected. |
Idempotency | reference | Recommended | A unique per-charge reference makes initialize safe to retry. |
Authentication
Pass your secret key as a Bearer token. Keys are mode-scoped: sk_test_… keys
operate on sandbox data only; sk_live_… keys return
403 live_not_enabled until your KYC review is approved. Keep secret keys on
your server — never ship them in a browser or mobile app.
Authorization: Bearer sk_test_xxxxxxxxxxxxxxxxThe response envelope
Every response is JSON. Successes carry status: true, a human
message, and a data payload; list endpoints add a
meta pagination block. Errors carry status: false plus a stable,
machine-readable code — branch on code, never on
message.
{
"status": true,
"message": "Charge initialized",
"data": { … }
}{
"status": false,
"code": "invalid_amount",
"message": "amount must be a positive integer in the currency's MINOR unit."
}Idempotency
Supply your own unique reference when you initialize a charge. Re-posting a
reference that names a still-open charge (pending or
awaiting_payment) with the same amount and currency returns the
existing charge unchanged — safe to retry on a network timeout. Any other reuse returns
409 duplicate_reference. References are unique platform-wide.
POST /transactions/initialize
Create a charge and receive a hosted checkout URL to redirect your customer to.
| Parameter | Type | Required | Notes |
|---|---|---|---|
amount | integer | Required | Positive integer in minor units. Digit-only strings accepted; floats rejected. Default limits 100 – 5,000,000,000. |
currency | string | Optional | ISO code. Defaults to NGN. Unknown codes → invalid_currency. |
email | string | Optional | Customer email; validated when present. Invalid → invalid_email. |
reference | string | Optional | Your unique ID, ^[A-Za-z0-9\-._=]{1,80}$. Auto-generated when omitted. Drives idempotency. |
callback_url | string | Optional | Public http(s) URL the customer returns to. Falls back to your account default. |
metadata | object | Optional | Any JSON object, ≤ 4096 bytes encoded. Echoed on verify and in webhooks. |
customer_name | string | Optional | Display name shown on checkout. |
customer_phone | string | Optional | Customer phone number. |
description | string | Optional | Shown on checkout; truncated to 250 characters. |
country | string | Optional | ISO-3166 alpha-2 hint for payment-rail selection. |
curl https://qoinpay.com/api/v1/transactions/initialize \
-H "Authorization: Bearer sk_test_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"amount": 500000,
"currency": "NGN",
"email": "customer@example.com",
"reference": "ORDER-2041-A7"
}'<?php
$ch = curl_init('https://qoinpay.com/api/v1/transactions/initialize');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer sk_test_xxxxxxxxxxxx',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'amount' => 500000, // ₦5,000.00 in kobo
'currency' => 'NGN',
'email' => 'customer@example.com',
'reference' => 'ORDER-2041-A7',
]),
]);
$res = json_decode(curl_exec($ch), true);
header('Location: ' . $res['data']['authorization_url']);const res = await fetch('https://qoinpay.com/api/v1/transactions/initialize', {
method: 'POST',
headers: {
Authorization: 'Bearer sk_test_xxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: 500000, // ₦5,000.00 in kobo
currency: 'NGN',
email: 'customer@example.com',
reference: 'ORDER-2041-A7',
}),
});
const { data } = await res.json();
console.log(data.authorization_url);import requests
res = requests.post(
'https://qoinpay.com/api/v1/transactions/initialize',
headers={'Authorization': 'Bearer sk_test_xxxxxxxxxxxx'},
json={
'amount': 500000, # ₦5,000.00 in kobo
'currency': 'NGN',
'email': 'customer@example.com',
'reference': 'ORDER-2041-A7',
},
)
print(res.json()['data']['authorization_url']){
"status": true,
"message": "Charge initialized",
"data": {
"authorization_url": "https://qoinpay.com/pay/ck_9f2ab41c77d0",
"access_code": "ck_9f2ab41c77d0",
"reference": "ORDER-2041-A7",
"amount": 500000,
"currency": "NGN"
}
}GET /transactions/verify/{reference}
Fetch the full transaction object by reference, scoped to your account and the key's mode —
a test key never sees live charges. Unknown reference → 404 transaction_not_found.
A paid charge is reported as "status": "success".
curl https://qoinpay.com/api/v1/transactions/verify/ORDER-2041-A7 \
-H "Authorization: Bearer sk_test_xxxxxxxxxxxx"<?php
$ref = 'ORDER-2041-A7';
$ch = curl_init("https://qoinpay.com/api/v1/transactions/verify/{$ref}");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_test_xxxxxxxxxxxx'],
]);
$txn = json_decode(curl_exec($ch), true)['data'];
if ($txn['status'] === 'success') {
// fulfil the order — verified server-side
}const ref = 'ORDER-2041-A7';
const res = await fetch(`https://qoinpay.com/api/v1/transactions/verify/${ref}`, {
headers: { Authorization: 'Bearer sk_test_xxxxxxxxxxxx' },
});
const { data } = await res.json();
if (data.status === 'success') {
// fulfil the order — verified server-side
}ref = 'ORDER-2041-A7'
res = requests.get(
f'https://qoinpay.com/api/v1/transactions/verify/{ref}',
headers={'Authorization': 'Bearer sk_test_xxxxxxxxxxxx'},
)
txn = res.json()['data']
if txn['status'] == 'success':
pass # fulfil the order — verified server-side{
"status": true,
"message": "Verification successful",
"data": {
"reference": "ORDER-2041-A7",
"amount": 500000,
"fee": 7500,
"net_amount": 492500,
"paid_amount": 500000,
"currency": "NGN",
"status": "success",
"payment_method": "card",
"channel": "checkout",
"customer_email": "customer@example.com",
"description": null,
"metadata": { "order_id": 2041 },
"authorization_url": "https://qoinpay.com/pay/ck_9f2ab41c77d0",
"mode": "test",
"country": "NG",
"created_at": "2026-08-15 09:12:44",
"paid_at": "2026-08-15 09:14:02"
}
}GET /transactions
List transactions for the key's mode, newest first, with a pagination meta block.
| Query param | Type | Required | Notes |
|---|---|---|---|
status | string | Optional | One of pending, awaiting_payment, success, failed, expired, refunded. Anything else → 422 invalid_status. |
from | string | Optional | Lower date bound on creation time, e.g. 2026-08-01. |
to | string | Optional | Upper date bound on creation time. |
page | integer | Optional | 1-based page number. Default 1. |
per_page | integer | Optional | 1–100. Default 50. |
curl "https://qoinpay.com/api/v1/transactions?status=success&from=2026-08-01&page=1&per_page=25" \
-H "Authorization: Bearer sk_test_xxxxxxxxxxxx"{
"status": true,
"message": "Transactions retrieved",
"data": [
{ …transaction object },
{ …transaction object }
],
"meta": {
"page": 1,
"per_page": 25,
"total": 214,
"total_pages": 9
}
}GET /balance
Live ledger balances grouped by currency, in minor units. Only live-mode activity writes to the ledger — test charges never appear here regardless of which key you call with.
curl https://qoinpay.com/api/v1/balance \
-H "Authorization: Bearer sk_live_xxxxxxxxxxxx"{
"status": true,
"message": "Ledger balances (live)",
"data": [
{ "currency": "NGN", "balance": 152300000 },
{ "currency": "USD", "balance": 84020 }
]
}GET /products
List the product engines your account is entitled to, alongside the full catalog. Each
catalog entry flags whether you are subscribed and its api_base.
Test-mode APIs are open the moment you hold a key; live access to a non-core engine
requires an active subscription (402 subscription_required otherwise).
curl https://qoinpay.com/api/v1/products \
-H "Authorization: Bearer sk_test_xxxxxxxxxxxx"{
"status": true,
"message": "Products & entitlements",
"data": {
"entitlements": [
{
"product": "qshield",
"name": "QShield",
"plan": "scale",
"status": "active",
"live_enabled": true,
"api_base": "/api/v1/qshield"
}
],
"catalog": [
{
"product": "qshield",
"name": "QShield",
"tagline": "Fraud & risk scoring",
"category": "risk",
"api_base": "/api/v1/qshield",
"is_core": false,
"subscribed": true
}
]
}
}POST /qpay/virtual-accounts
Mint a reserved bank virtual account bound to one of your customers, for recurring bank-transfer collections. Every transfer into the account is matched to the customer and confirmed via the QPay webhook. Unlike the one-time accounts minted per checkout, a reserved account is long-lived.
| Parameter | Type | Required | Notes |
|---|---|---|---|
customer_ref | string | Required | Your stable identifier for the customer. The account is bound to it; reuse returns the existing reserved account. |
customer_name | string | Required | Customer display name; used to derive the account name. |
currency | string | Optional | Defaults to NGN. Reserved accounts are NGN today. |
curl https://qoinpay.com/api/v1/qpay/virtual-accounts \
-H "Authorization: Bearer sk_live_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"customer_ref": "CUST-8842",
"customer_name": "Adaeze Okonkwo"
}'const res = await fetch('https://qoinpay.com/api/v1/qpay/virtual-accounts', {
method: 'POST',
headers: {
Authorization: 'Bearer sk_live_xxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
customer_ref: 'CUST-8842',
customer_name: 'Adaeze Okonkwo',
}),
});
const { data } = await res.json();
console.log(data.account_number, data.bank_name);res = requests.post(
'https://qoinpay.com/api/v1/qpay/virtual-accounts',
headers={'Authorization': 'Bearer sk_live_xxxxxxxxxxxx'},
json={
'customer_ref': 'CUST-8842',
'customer_name': 'Adaeze Okonkwo',
},
)
data = res.json()['data']
print(data['account_number'], data['bank_name']){
"status": true,
"message": "Reserved virtual account created",
"data": {
"account_ref": "QVA-4C1F0A9D",
"account_number": "7290043118",
"account_name": "QoinPay / Adaeze Okonkwo",
"bank_name": "QoinPay MFB",
"customer_ref": "CUST-8842",
"currency": "NGN",
"status": "active"
}
}QPay webhook
When a customer transfers into a QPay virtual account, the bank rail notifies QoinPay, which
resolves the credit to your charge and settles it. QoinPay then fires the standard
charge.success webhook to your endpoints — the transaction object is
the payload. You verify that with your whsec_… secret exactly like any other
QoinPay event; see Webhook payload.
The inbound leg (bank rail → QoinPay) is signed with X-QPay-Signature, a hex
HMAC-SHA512 of the raw body. QoinPay matches the credit by your charge reference, falling back
to the credited account number, and settles only when the credited amount covers the expected
amount. Delivery is idempotent on the provider event id, so a replayed credit never
double-settles.
POST /api/qpay/webhook
X-QPay-Signature: 5f3a…e91 (hex HMAC-SHA512 of the raw body)
{
"event_id": "evt_qpay_7f21a0",
"event": "credit",
"reference": "ORDER-2041-A7",
"account_number": "7290043118",
"amount": 5000,
"currency": "NGN"
}The transaction object
Returned by verify, each element of list, and as the
payload of the charge.success / charge.failed webhooks. All amount
fields are integer minor units.
| Field | Type | Nullable | Description |
|---|---|---|---|
reference | string | — | Your reference, or the auto-generated one. Unique platform-wide. |
amount | integer | — | Charge amount in minor units. |
fee | integer | — | QoinPay fee for the charge, in minor units. |
net_amount | integer | — | Amount settled to you after fees (amount − fee). |
paid_amount | integer | nullable | Amount actually paid; null until the charge is paid. |
currency | string | — | ISO currency code, e.g. NGN. |
status | string | — | success (paid), pending, awaiting_payment, failed, expired, refunded, partially_refunded. |
payment_method | string | nullable | Rail used, e.g. card, bank_transfer. Null before payment. |
channel | string | nullable | How the charge was created, e.g. checkout, qpay. |
customer_email | string | nullable | Customer email, when supplied. |
description | string | nullable | Description shown on checkout, when supplied. |
metadata | object | nullable | The JSON object you attached at initialize, echoed back. |
authorization_url | string | nullable | Hosted checkout URL for the charge. |
mode | string | — | test or live — the mode of the key that created it. |
country | string | nullable | ISO-3166 alpha-2 country hint. |
created_at | string | — | Creation timestamp, UTC, YYYY-MM-DD HH:MM:SS. |
paid_at | string | nullable | Payment timestamp; null until paid. |
Webhook payload
QoinPay POSTs JSON events to your configured endpoints. The body of a
charge.success or charge.failed event is the full
transaction object. Verify the signature on every delivery,
respond 2xx within a few seconds, and treat deliveries as at-least-once —
deduplicate on X-QoinPay-Event-Id.
| Header | Kind | Contents | |
|---|---|---|---|
X-QoinPay-Signature | header | — | Hex HMAC-SHA512 of the raw request body, keyed with your endpoint whsec_… secret. |
X-QoinPay-Event | header | — | The event name, e.g. charge.success. |
X-QoinPay-Event-Id | header | — | Unique event id — deduplicate retried deliveries on it. |
| Event | Kind | Fires when | |
|---|---|---|---|
charge.success | event | — | A charge is confirmed paid. Payload: the transaction object. |
charge.failed | event | — | A charge fails terminally. Payload: the transaction object. |
ping | event | — | Emitted by POST /webhooks/test — a signed no-op for testing your verification. |
POST https://your-app.example.com/webhooks/qoinpay
X-QoinPay-Event: charge.success
X-QoinPay-Event-Id: evt_5c41a02be7
X-QoinPay-Signature: 9d1c…af (hex HMAC-SHA512 of the raw body)
Content-Type: application/json
{
"reference": "ORDER-2041-A7",
"amount": 500000,
"currency": "NGN",
"status": "success",
"payment_method": "card",
"paid_at": "2026-08-15 09:14:02",
"metadata": { "order_id": 2041 }
}<?php
// Verify a QoinPay webhook signature
$rawBody = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_QOINPAY_SIGNATURE'] ?? '';
$secret = getenv('QOINPAY_WHSEC');
if (!hash_equals(hash_hmac('sha512', $rawBody, $secret), $sig)) {
http_response_code(401);
exit;
}
http_response_code(200); // acknowledge fast
$event = json_decode($rawBody, true);
// dedupe on $_SERVER['HTTP_X_QOINPAY_EVENT_ID'], then process asyncconst crypto = require('crypto');
// express with express.raw({ type: 'application/json' })
app.post('/webhooks/qoinpay', (req, res) => {
const sig = req.get('X-QoinPay-Signature');
const expected = crypto
.createHmac('sha512', process.env.QOINPAY_WHSEC)
.update(req.body) // the raw Buffer
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.sendStatus(401);
}
res.sendStatus(200); // acknowledge fast
const event = JSON.parse(req.body); // dedupe on X-QoinPay-Event-Id
});import hmac, hashlib, os
# Flask — read the RAW body, not the parsed form
raw = request.get_data()
sig = request.headers.get('X-QoinPay-Signature', '')
expected = hmac.new(
os.environ['QOINPAY_WHSEC'].encode(),
raw,
hashlib.sha512,
).hexdigest()
if not hmac.compare_digest(expected, sig):
abort(401)
# 200 fast; dedupe on X-QoinPay-Event-Id, then process async
return '', 200Error codes
Errors always use the envelope {"status": false, "code": "…", "message": "…"}.
Branch on the stable code, not the human message. Each key may make
120 requests per minute; beyond that requests return
429 rate_limited until the window resets.
| HTTP | Code | Meaning | |
|---|---|---|---|
401 | missing_key | — | No Authorization: Bearer header was sent. |
401 | invalid_key | — | The key is unknown or has been revoked. |
402 | subscription_required | — | Live call to a product engine you are not subscribed to. |
403 | live_not_enabled | — | Live key used before KYC approval. Use test keys. |
403 | merchant_suspended / merchant_closed | — | The merchant account is not active. |
403 | blocked_by_risk | — | QShield risk screening declined the charge. |
404 | transaction_not_found | — | No transaction with that reference in this key's mode. |
404 | bill_not_found | — | Unknown QCollect bill reference. |
409 | duplicate_reference | — | Reference reused with different terms, or already completed. |
409 | refund_in_progress | — | Another refund is already being processed for the charge. |
422 | invalid_amount | — | Amount is not a positive integer in minor units. |
422 | amount_too_small / amount_too_large | — | Outside the configured charge limits (default 100 – 5,000,000,000). |
422 | invalid_currency | — | Currency code is not supported. |
422 | invalid_email | — | Email fails validation. |
422 | invalid_reference | — | Reference violates ^[A-Za-z0-9\-._=]{1,80}$. |
422 | invalid_callback_url | — | Callback is not a public http(s) URL. |
422 | invalid_metadata / metadata_too_large | — | Metadata is not a JSON object, or exceeds 4096 bytes encoded. |
422 | invalid_status | — | Unsupported status filter on the list endpoint. |
422 | not_refundable | — | Refund attempted on a transaction that is not paid. |
422 | bill_error | — | QCollect bill validation failed (see message). |
429 | rate_limited | — | More than 120 requests in a minute on this key. |
429 | too_many_attempts | — | Too many failed authentications from your IP. Back off and retry. |
502 | refund_failed | — | The processing gateway declined or errored on the refund. |