Reference

QoinPay REST API v1

Every core endpoint, object and error code — documented against the live implementation. One base URL, Bearer-key auth, and a predictable {status, message, data} envelope. Amounts are always integer minor units (kobo). New here? Start with the integration guide.

Conventions

The rules below hold for every endpoint on the API. Read them once and the rest of this reference is mechanical.

ConventionValueWhenNotes
Base URLhttps://qoinpay.com/api/v1HTTPS onlyAll paths in this reference are relative to it.
AuthenticationAuthorization: Bearer sk_…RequiredMode-scoped secret key on every request. Server-side only.
Content typeapplication/jsonOn writesSend a JSON body on POST; UTF-8.
Amountsinteger minor unitsAlwaysKobo for NGN, cents for USD. ₦5,000.00 = 500000. Floats are rejected.
IdempotencyreferenceRecommendedA 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 header
Authorization: Bearer sk_test_xxxxxxxxxxxxxxxx

The 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.

Success
{
  "status": true,
  "message": "Charge initialized",
  "data": { … }
}
Error
{
  "status": false,
  "code": "invalid_amount",
  "message": "amount must be a positive integer in the currency's MINOR unit."
}
Amounts are integer minor units. Send and read every amount in the currency's smallest unit — kobo for NGN. 500000 means ₦5,000.00. Never send decimals or floats; they are rejected with invalid_amount.

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.

ParameterTypeRequiredNotes
amountintegerRequiredPositive integer in minor units. Digit-only strings accepted; floats rejected. Default limits 100 – 5,000,000,000.
currencystringOptionalISO code. Defaults to NGN. Unknown codes → invalid_currency.
emailstringOptionalCustomer email; validated when present. Invalid → invalid_email.
referencestringOptionalYour unique ID, ^[A-Za-z0-9\-._=]{1,80}$. Auto-generated when omitted. Drives idempotency.
callback_urlstringOptionalPublic http(s) URL the customer returns to. Falls back to your account default.
metadataobjectOptionalAny JSON object, ≤ 4096 bytes encoded. Echoed on verify and in webhooks.
customer_namestringOptionalDisplay name shown on checkout.
customer_phonestringOptionalCustomer phone number.
descriptionstringOptionalShown on checkout; truncated to 250 characters.
countrystringOptionalISO-3166 alpha-2 hint for payment-rail selection.
cURL
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
<?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']);
Node.js
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);
Python
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'])
Response — 200
{
  "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"
  }
}
Verify server-side before you fulfil. Redirect the customer to authorization_url. When they return, or when the charge.success webhook fires, call verify to confirm the outcome from your server. Never trust the browser redirect alone.

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
curl https://qoinpay.com/api/v1/transactions/verify/ORDER-2041-A7 \
  -H "Authorization: Bearer sk_test_xxxxxxxxxxxx"
PHP
<?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
}
Node.js
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
}
Python
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
Response — 200
{
  "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 paramTypeRequiredNotes
statusstringOptionalOne of pending, awaiting_payment, success, failed, expired, refunded. Anything else → 422 invalid_status.
fromstringOptionalLower date bound on creation time, e.g. 2026-08-01.
tostringOptionalUpper date bound on creation time.
pageintegerOptional1-based page number. Default 1.
per_pageintegerOptional1–100. Default 50.
cURL
curl "https://qoinpay.com/api/v1/transactions?status=success&from=2026-08-01&page=1&per_page=25" \
  -H "Authorization: Bearer sk_test_xxxxxxxxxxxx"
Response — 200
{
  "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
curl https://qoinpay.com/api/v1/balance \
  -H "Authorization: Bearer sk_live_xxxxxxxxxxxx"
Response — 200
{
  "status": true,
  "message": "Ledger balances (live)",
  "data": [
    { "currency": "NGN", "balance": 152300000 },
    { "currency": "USD", "balance": 84020 }
  ]
}
Balances are minor units too. A NGN balance of 152300000 is ₦1,523,000.00. Divide by 100 for display; keep the integer for any arithmetic.

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
curl https://qoinpay.com/api/v1/products \
  -H "Authorization: Bearer sk_test_xxxxxxxxxxxx"
Response — 200
{
  "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.

Rolling out. The reserved virtual-account API is a documented QPay capability being enabled per merchant. Request access from your dashboard; the request/response shape below is stable and matches the QPay account records.
ParameterTypeRequiredNotes
customer_refstringRequiredYour stable identifier for the customer. The account is bound to it; reuse returns the existing reserved account.
customer_namestringRequiredCustomer display name; used to derive the account name.
currencystringOptionalDefaults to NGN. Reserved accounts are NGN today.
cURL
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"
  }'
Node.js
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);
Python
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'])
Response — 200
{
  "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.

Inbound credit event (bank rail → QoinPay)
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"
}
You consume charge.success, not the raw credit. The inbound credit event is between the bank rail and QoinPay. Your integration only ever handles the resulting charge.success webhook, whose payload is the full transaction object.

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.

FieldTypeNullableDescription
referencestringYour reference, or the auto-generated one. Unique platform-wide.
amountintegerCharge amount in minor units.
feeintegerQoinPay fee for the charge, in minor units.
net_amountintegerAmount settled to you after fees (amount − fee).
paid_amountintegernullableAmount actually paid; null until the charge is paid.
currencystringISO currency code, e.g. NGN.
statusstringsuccess (paid), pending, awaiting_payment, failed, expired, refunded, partially_refunded.
payment_methodstringnullableRail used, e.g. card, bank_transfer. Null before payment.
channelstringnullableHow the charge was created, e.g. checkout, qpay.
customer_emailstringnullableCustomer email, when supplied.
descriptionstringnullableDescription shown on checkout, when supplied.
metadataobjectnullableThe JSON object you attached at initialize, echoed back.
authorization_urlstringnullableHosted checkout URL for the charge.
modestringtest or live — the mode of the key that created it.
countrystringnullableISO-3166 alpha-2 country hint.
created_atstringCreation timestamp, UTC, YYYY-MM-DD HH:MM:SS.
paid_atstringnullablePayment 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.

HeaderKindContents
X-QoinPay-SignatureheaderHex HMAC-SHA512 of the raw request body, keyed with your endpoint whsec_… secret.
X-QoinPay-EventheaderThe event name, e.g. charge.success.
X-QoinPay-Event-IdheaderUnique event id — deduplicate retried deliveries on it.
EventKindFires when
charge.successeventA charge is confirmed paid. Payload: the transaction object.
charge.failedeventA charge fails terminally. Payload: the transaction object.
pingeventEmitted by POST /webhooks/test — a signed no-op for testing your verification.
POST to your endpoint
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
<?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 async
Node.js
const 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
});
Python
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 '', 200

Error 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.

HTTPCodeMeaning
401missing_keyNo Authorization: Bearer header was sent.
401invalid_keyThe key is unknown or has been revoked.
402subscription_requiredLive call to a product engine you are not subscribed to.
403live_not_enabledLive key used before KYC approval. Use test keys.
403merchant_suspended / merchant_closedThe merchant account is not active.
403blocked_by_riskQShield risk screening declined the charge.
404transaction_not_foundNo transaction with that reference in this key's mode.
404bill_not_foundUnknown QCollect bill reference.
409duplicate_referenceReference reused with different terms, or already completed.
409refund_in_progressAnother refund is already being processed for the charge.
422invalid_amountAmount is not a positive integer in minor units.
422amount_too_small / amount_too_largeOutside the configured charge limits (default 100 – 5,000,000,000).
422invalid_currencyCurrency code is not supported.
422invalid_emailEmail fails validation.
422invalid_referenceReference violates ^[A-Za-z0-9\-._=]{1,80}$.
422invalid_callback_urlCallback is not a public http(s) URL.
422invalid_metadata / metadata_too_largeMetadata is not a JSON object, or exceeds 4096 bytes encoded.
422invalid_statusUnsupported status filter on the list endpoint.
422not_refundableRefund attempted on a transaction that is not paid.
422bill_errorQCollect bill validation failed (see message).
429rate_limitedMore than 120 requests in a minute on this key.
429too_many_attemptsToo many failed authentications from your IP. Back off and retry.
502refund_failedThe processing gateway declined or errored on the refund.