Guide

Integration guide

Accept your first payment with QoinPay in minutes. Initialize a charge, send your customer to hosted checkout, and verify the result server-side — one API, cards, bank transfer, USSD and QR out of the box.

Introduction

The QoinPay API is a JSON-over-HTTPS REST API for accepting and reconciling payments. You create a charge with a single call, redirect your customer to a hosted checkout page that handles cards, bank transfer, USSD and QR, and then confirm the outcome server-side by verifying the transaction. Every response uses a predictable envelope, and every write is safe to retry.

Base URLhttps://qoinpay.com/api/v1
FormatJSON request and response bodies (Content-Type: application/json)
AmountsInteger minor units (kobo) — never floats. ₦5,000 = 500000
AuthenticationBearer secret key — sk_test_… in test, sk_live_… in live
IdempotencyKeyed by your reference — retries return the same charge
Idempotent by reference. Retrying an initialize with the same reference (same amount and currency) returns the same charge instead of creating a duplicate — network timeouts are safe to retry.

Authentication

Every request authenticates with a secret key in the Authorization header as a Bearer token. Keys are mode-scoped: a sk_test_ key only ever touches sandbox data, and a sk_live_ key only works once your KYC review is approved.

Grab your keys from your dashboard under Developers → API keys. Test keys are active the moment your account exists, so you can build the whole flow before you are approved for live.

Authenticated request
curl https://qoinpay.com/api/v1/merchant \
  -H "Authorization: Bearer sk_test_xxxxxxxxxxxxxxxxxxxx"
Keep secret keys secret. Never expose an sk_ key in client-side code, mobile apps, or a public repo. All charge creation and verification happens from your server. If a key leaks, roll it from Developers → API keys.

Quickstart

Four minutes, four moves. Do the work on your server so your secret key never reaches the browser.

  1. Create an account and grab a test key. Sign up, then copy your sk_test_ key from Developers → API keys.
  2. Initialize a charge. POST /transactions/initialize with an amount (in kobo), a customer email and your unique reference. You get back an authorization_url.
  3. Send the customer to checkout. Redirect the browser to data.authorization_url — QoinPay hosts the payment page.
  4. Verify. After payment, call GET /transactions/verify/{reference} and only fulfil the order when status is success.

Accept a payment

The whole flow is four steps: initialize a charge, redirect the customer, receive the callback, and verify server-side.

Step 1 — Initialize the charge

POST https://qoinpay.com/api/v1/transactions/initialize

ParameterTypeRequiredNotes
amountintegerRequiredCharge amount in kobo (minor units). ₦5,000 = 500000. Positive integer only — no floats.
emailstringRequiredCustomer email address; receipts and checkout use it.
referencestringRequiredYour unique idempotency key. Match ^[A-Za-z0-9-._=]{1,80}$. Reused references return the same charge (or 409 on conflicting terms).
currencystringOptionalISO currency code. Defaults to NGN.
callback_urlstringOptionalPublic https URL the customer returns to after checkout. Falls back to your account default.
metadataobjectOptionalArbitrary JSON object (≤ 4096 bytes) echoed back on verify and in webhooks.
cURL
curl https://qoinpay.com/api/v1/transactions/initialize \
  -H "Authorization: Bearer sk_test_xxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 500000,
    "currency": "NGN",
    "email": "customer@example.com",
    "reference": "ORDER-2041-A7",
    "callback_url": "https://yourapp.com/callback"
  }'
PHP
<?php
$ch = curl_init('https://qoinpay.com/api/v1/transactions/initialize');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer sk_test_xxxxxxxxxxxxxxxxxxxx',
        'Content-Type: application/json',
    ],
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => json_encode([
        'amount'    => 500000,           // ₦5,000 in kobo
        'currency'  => 'NGN',
        'email'     => 'customer@example.com',
        'reference' => 'ORDER-2041-A7',  // your unique id
    ]),
]);
$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_xxxxxxxxxxxxxxxxxxxx",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    amount: 500000,          // ₦5,000 in kobo
    currency: "NGN",
    email: "customer@example.com",
    reference: "ORDER-2041-A7",
  }),
});

const { data } = await res.json();
// Redirect the customer to checkout:
response.redirect(data.authorization_url);
Python
import requests

res = requests.post(
    "https://qoinpay.com/api/v1/transactions/initialize",
    headers={
        "Authorization": "Bearer sk_test_xxxxxxxxxxxxxxxxxxxx",
        "Content-Type": "application/json",
    },
    json={
        "amount": 500000,          # ₦5,000 in kobo
        "currency": "NGN",
        "email": "customer@example.com",
        "reference": "ORDER-2041-A7",
    },
)

data = res.json()["data"]
# Redirect the customer to 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"
  }
}

Step 2 — Redirect the customer

Send the customer's browser to data.authorization_url. QoinPay hosts the checkout page, presents the available payment methods, and handles the payment end to end — you write no card, transfer or 3-D Secure UI yourself.

Step 3 — Handle the callback

After checkout, QoinPay redirects the browser back to your callback_url with the reference appended. Treat the callback purely as a signal to look up the transaction — never as proof of payment.

Never fulfil on the callback alone. A returning browser is not a confirmed payment. Always call verify server-side (Step 4) — or wait for the charge.success webhook — before you release goods or credit an account.

Step 4 — Verify

Call verify with the same reference and fulfil only when the transaction is paid. See Verify a payment for the full call and response.

QPay: cards, transfer & virtual accounts

QPay is QoinPay's own default gateway — the rails behind hosted checkout. You need zero extra code to use it: the same /transactions/initialize call routes Nigerian charges to QPay automatically, and the hosted checkout offers every method your customer expects.

  • Card — full card entry with built-in 3-D Secure.
  • Bank transfer via a dynamic virtual account — Monnify-style. The payer is shown a one-time account number for exactly this charge, transfers the exact amount, and QPay auto-confirms the credit and moves the charge to success — no manual reconciliation.
  • USSD — a dial code the payer completes from their banking app or phone.
  • QR — scan-to-pay from a supported mobile banking or wallet app.

For repeat customers you can also issue reserved (recurring) virtual accounts — a fixed account number tied to a customer, so any transfer into it is attributed and confirmed automatically. Dynamic accounts are per-charge; reserved accounts are per-customer.

QPay is the default — you already support it. Every initialize you send routes to QPay for NG merchants, so card, transfer, USSD and QR all work the moment your first charge goes out. There is nothing extra to enable.
Same call — routes to QPay
# No QPay-specific fields needed. This standard initialize
# routes to QPay for NG and enables card + transfer + USSD + QR.
curl https://qoinpay.com/api/v1/transactions/initialize \
  -H "Authorization: Bearer sk_test_xxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"amount":500000,"currency":"NGN","email":"customer@example.com","reference":"ORDER-2041-A7"}'

Verify a payment

GET https://qoinpay.com/api/v1/transactions/verify/{reference}

Verify is the source of truth. Look the transaction up by your reference, and treat it as paid only when status is success. References are scoped to your account and the key's mode — a test key never sees live charges.

cURL
curl https://qoinpay.com/api/v1/transactions/verify/ORDER-2041-A7 \
  -H "Authorization: Bearer sk_test_xxxxxxxxxxxxxxxxxxxx"
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",
    "metadata": { "order_id": 2041 },
    "mode": "test",
    "paid_at": "2026-08-15 09:14:02"
  }
}
success means paid. The API reports a paid charge as "status": "success". Confirm the amount and currency match your order before you fulfil, since verify returns the charge exactly as it settled.

Webhooks

QoinPay POSTs signed JSON events to the endpoints you configure, so you can fulfil orders asynchronously without polling. Every delivery is signed — verify the signature before you trust the body.

Events

EventTypeFires when
charge.successeventA charge is confirmed paid. Payload is the full transaction object (same shape as verify).
charge.failedeventA charge fails terminally. Payload is the full transaction object.
pingeventA signed no-op sent when you call POST /webhooks/test — use it to exercise signature verification.

Headers

HeaderTypeContents
X-QoinPay-SignaturestringHex HMAC-SHA512 of the raw request body, keyed with your endpoint whsec_ secret.
X-QoinPay-EventstringThe event name, e.g. charge.success.
X-QoinPay-Event-IdstringUnique event id — deduplicate retried deliveries on this.

Example event body

POST to your endpoint
{
  "event": "charge.success",
  "data": {
    "reference": "ORDER-2041-A7",
    "amount": 500000,
    "currency": "NGN",
    "status": "success",
    "payment_method": "bank_transfer",
    "customer_email": "customer@example.com",
    "metadata": { "order_id": 2041 },
    "paid_at": "2026-08-15 09:14:02"
  }
}

Verify the signature

PHP
<?php
// Compute HMAC-SHA512 over the RAW body and compare in constant time.
$raw    = file_get_contents('php://input');
$sig    = $_SERVER['HTTP_X_QOINPAY_SIGNATURE'] ?? '';
$secret = getenv('QOINPAY_WHSEC');       // your endpoint's whsec_ secret

$expected = hash_hmac('sha512', $raw, $secret);
if (!hash_equals($expected, $sig)) {
    http_response_code(401);
    exit;
}

http_response_code(200);                 // acknowledge fast
$event = json_decode($raw, true);        // then process asynchronously
Node.js
const crypto = require("crypto");

// IMPORTANT: use the raw request body, not the parsed JSON.
function verify(rawBody, header, secret) {
  const expected = crypto
    .createHmac("sha512", secret)
    .update(rawBody)
    .digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(header || "")
  );
}

app.post("/webhooks/qoinpay", (req, res) => {
  const ok = verify(req.rawBody, req.get("X-QoinPay-Signature"), process.env.QOINPAY_WHSEC);
  if (!ok) return res.sendStatus(401);
  res.sendStatus(200);              // acknowledge, then process
});

Delivery rules

  • Respond with a 2xx within a few seconds — do the heavy work after you acknowledge.
  • Deliveries are at-least-once: deduplicate on X-QoinPay-Event-Id so retries don't double-fulfil.
  • Failed deliveries (non-2xx, timeout) are retried with backoff.
  • Always verify the signature over the raw body before trusting the payload.

Test mode

Test keys work immediately — no KYC, no waiting. Any charge created with a sk_test_ key runs entirely in the sandbox and never touches real money or your live ledger.

On QPay's hosted checkout, test mode lets you confirm the transfer yourself to simulate a credit: pick bank transfer, then use the checkout's confirm control to move the charge to success exactly as a real inbound transfer would — so you can exercise your verify handler and charge.success webhook end to end before going live.

Errors & rate limits

Errors use a consistent envelope. Branch on the machine-readable field, not on the human message.

Error envelope
{
  "status": false,
  "code": "duplicate_reference",
  "message": "reference already used with different terms"
}
HTTPCodeWhen
400bad_requestMalformed request — invalid JSON or a missing required field.
401invalid_keyMissing, unknown or revoked Authorization Bearer key.
404transaction_not_foundNo transaction with that reference in this key's mode.
409duplicate_referenceReference reused with different amount/currency, or already completed.
422invalid_amountAmount is not a positive integer in minor units, or fails validation (also invalid_email, invalid_currency, etc.).
429rate_limitedMore than 120 requests in a minute on this key. Back off and retry after the window resets.

Go-live checklist

When your sandbox integration is solid, switch to live in five steps.

  1. Complete your business onboarding profile in the dashboard.
  2. Submit and pass KYC review to unlock live keys.
  3. Switch your server to your sk_live_ key (keep test and live keys separate per environment).
  4. Set your production webhook URL and verify a signed ping from Developers → Webhooks.
  5. Run one small real charge end to end — initialize, pay, verify, receive the webhook — before you launch.

Full field-by-field details for every endpoint live in the API reference.