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 URL | https://qoinpay.com/api/v1 |
| Format | JSON request and response bodies (Content-Type: application/json) |
| Amounts | Integer minor units (kobo) — never floats. ₦5,000 = 500000 |
| Authentication | Bearer secret key — sk_test_… in test, sk_live_… in live |
| Idempotency | Keyed by your reference — retries return the same charge |
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.
curl https://qoinpay.com/api/v1/merchant \
-H "Authorization: Bearer sk_test_xxxxxxxxxxxxxxxxxxxx"Quickstart
Four minutes, four moves. Do the work on your server so your secret key never reaches the browser.
- Create an account and grab a test key. Sign up, then copy your
sk_test_key from Developers → API keys. - Initialize a charge. POST
/transactions/initializewith an amount (in kobo), a customer email and your uniquereference. You get back anauthorization_url. - Send the customer to checkout. Redirect the browser to
data.authorization_url— QoinPay hosts the payment page. - Verify. After payment, call
GET
/transactions/verify/{reference}and only fulfil the order whenstatusissuccess.
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
| Parameter | Type | Required | Notes |
|---|---|---|---|
amount | integer | Required | Charge amount in kobo (minor units). ₦5,000 = 500000. Positive integer only — no floats. |
email | string | Required | Customer email address; receipts and checkout use it. |
reference | string | Required | Your unique idempotency key. Match ^[A-Za-z0-9-._=]{1,80}$. Reused references return the same charge (or 409 on conflicting terms). |
currency | string | Optional | ISO currency code. Defaults to NGN. |
callback_url | string | Optional | Public https URL the customer returns to after checkout. Falls back to your account default. |
metadata | object | Optional | Arbitrary JSON object (≤ 4096 bytes) echoed back on verify and in webhooks. |
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
$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']);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);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"]{
"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.
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.
# 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 https://qoinpay.com/api/v1/transactions/verify/ORDER-2041-A7 \
-H "Authorization: Bearer sk_test_xxxxxxxxxxxxxxxxxxxx"{
"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"
}
}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
| Event | Type | Fires when | |
|---|---|---|---|
charge.success | event | — | A charge is confirmed paid. Payload is the full transaction object (same shape as verify). |
charge.failed | event | — | A charge fails terminally. Payload is the full transaction object. |
ping | event | — | A signed no-op sent when you call POST /webhooks/test — use it to exercise signature verification. |
Headers
| Header | Type | Contents | |
|---|---|---|---|
X-QoinPay-Signature | string | — | Hex HMAC-SHA512 of the raw request body, keyed with your endpoint whsec_ secret. |
X-QoinPay-Event | string | — | The event name, e.g. charge.success. |
X-QoinPay-Event-Id | string | — | Unique event id — deduplicate retried deliveries on this. |
Example event body
{
"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
// 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 asynchronouslyconst 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
2xxwithin a few seconds — do the heavy work after you acknowledge. - Deliveries are at-least-once: deduplicate on
X-QoinPay-Event-Idso 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.
{
"status": false,
"code": "duplicate_reference",
"message": "reference already used with different terms"
}| HTTP | Code | When | |
|---|---|---|---|
400 | bad_request | — | Malformed request — invalid JSON or a missing required field. |
401 | invalid_key | — | Missing, unknown or revoked Authorization Bearer key. |
404 | transaction_not_found | — | No transaction with that reference in this key's mode. |
409 | duplicate_reference | — | Reference reused with different amount/currency, or already completed. |
422 | invalid_amount | — | Amount is not a positive integer in minor units, or fails validation (also invalid_email, invalid_currency, etc.). |
429 | rate_limited | — | More 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.
- Complete your business onboarding profile in the dashboard.
- Submit and pass KYC review to unlock live keys.
- Switch your server to your
sk_live_key (keep test and live keys separate per environment). - Set your production webhook URL and verify a signed
pingfrom Developers → Webhooks. - 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.