QR Payments
QR payments are the smartphone-native alternative to USSD. The flow has two sides: a merchant creates a payment intent and displays a QR code, and a payer scans it from their wallet app to confirm and pay.
Fee: 1.5% of amount, capped at ₦500 (50000 kobo).
Overview
The flow runs in two distinct steps, executed by two different parties:
- Merchant side — your server creates a payment intent for a specific amount and gets back a QR payload to render as an image.
- Payer side — the customer scans that QR code with their wallet-enabled app, which decodes it and submits a payment request against the intent.
Creating a payment intent (merchant side)
curl -X POST https://api.paykore.dev/v1/payments/qr/intent \
-H "X-API-Key: sk_test_YOUR_KEY_HERE" \
-H "Content-Type: application/json" \
-d '{
"merchant_wallet_id": "wlt_mErChAnT456",
"amount_kobo": 500000,
"reference": "order_789"
}'
Response:
{
"intent_id": "qri_4hJkL5mNpQ",
"qr_payload": "{\"pc\":\"paykore\",\"id\":\"qri_4hJkL5mNpQ\",\"amt\":500000,\"ref\":\"order_789\"}",
"amount_kobo": 500000,
"status": "pending",
"expires_at": "2025-06-01T12:15:00Z",
"meta": {
"request_id": "req_6pQrS7tUvW"
}
}
The qr_payload is a JSON string, inspired by the EMVCo QR standard, containing the minimum fields a payer app needs to act on it:
{
"pc": "paykore",
"id": "qri_4hJkL5mNpQ",
"amt": 500000,
"ref": "order_789"
}
| Field | Meaning |
|---|---|
pc | Identifies this as a PayKore QR payload (lets wallet apps route correctly). |
id | The intent ID — what the payer app submits back. |
amt | Amount in kobo, for display before confirmation. |
ref | Your merchant reference. |
Generating the QR image
Encode the qr_payload string — not the bare intent_id — into the QR image. The payload carries enough information for the payer app to display amount and merchant context before the user confirms.
TypeScript (web, using qrcode):
import QRCode from 'qrcode';
async function renderQR(canvas: HTMLCanvasElement, qrPayload: string) {
await QRCode.toCanvas(canvas, qrPayload, {
width: 280,
margin: 2,
});
}
// Usage
const canvas = document.getElementById('qr-canvas') as HTMLCanvasElement;
await renderQR(canvas, intent.qr_payload);
React Native (using react-native-qrcode-svg):
import QRCode from 'react-native-qrcode-svg';
function PaymentQR({ qrPayload }: { qrPayload: string }) {
return <QRCode value={qrPayload} size={240} />;
}
Paying a QR intent (payer side)
The payer's app scans the QR code, decodes the JSON payload, extracts id, and submits a payment against it:
curl -X POST https://api.paykore.dev/v1/payments/qr/pay \
-H "X-API-Key: sk_test_YOUR_KEY_HERE" \
-H "Content-Type: application/json" \
-d '{
"intent_id": "qri_4hJkL5mNpQ",
"payer_wallet_id": "wlt_9f3kA2mXpQ"
}'
Response — successful payment:
{
"id": "tx_5kMnO6pQrS",
"type": "qr_payment",
"status": "completed",
"reference": "order_789",
"amount_kobo": 500000,
"amount_naira": "5000.00",
"fee_breakdown": {
"customer_fee_kobo": 7500,
"platform_fee_kobo": 7500,
"net_amount_kobo": 500000
},
"created_at": "2025-06-01T12:05:00Z",
"meta": {
"request_id": "req_8sTuV9wXyZ"
}
}
Intent expiry and idempotency
QR intents expire 15 minutes after creation. Scanning an expired QR returns 410 Gone:
{
"error": {
"code": "INTENT_EXPIRED",
"message": "This QR payment intent expired and is no longer payable.",
"http_status": 410
},
"meta": {
"request_id": "req_2tUvW3xYzA"
}
}
A given intent can only be paid once. The intent is consumed on first successful payment — attempting to pay it again returns 409 Conflict:
{
"error": {
"code": "INTENT_ALREADY_PAID",
"message": "This payment intent has already been paid and cannot be paid again.",
"http_status": 409
},
"meta": {
"request_id": "req_5wXyZ6aBcD"
}
}
This single-use behaviour is intentional. It prevents a customer from accidentally scanning and confirming the same QR code twice, which would otherwise double-charge them. If you need to charge the same amount again, create a new intent.
Static vs dynamic QR codes
PayKore currently issues dynamic QR codes — each intent has a fixed amount embedded, generated fresh per transaction. This is the right model for checkout flows where the amount is known upfront.
Static QR codes — a single, reusable QR code (e.g. printed at a market stall) where the payer types in the amount themselves — are not yet available. This is on the product roadmap for businesses that need an "always-on" payment code rather than a per-transaction one.
End-to-end example
A typical checkout flow:
- Customer taps "Pay with QR" in your merchant-facing app.
- Your server calls
POST /v1/payments/qr/intentfor the order total. - Your app renders the returned
qr_payloadas a QR code on screen. - The customer opens their wallet app and scans the code.
- Their app decodes the payload, shows them the amount and your merchant reference, and asks for confirmation.
- On confirmation, their app calls
POST /v1/payments/qr/pay. - PayKore processes the transfer and fires
transaction.completedto your registered webhook. - Your server receives the webhook, matches it by
reference, and marks the order as paid. - Your app polls or listens for that state change and shows the customer a success screen.
Webhook
{
"event": "transaction.completed",
"data": {
"id": "tx_5kMnO6pQrS",
"type": "qr_payment",
"status": "completed",
"reference": "order_789",
"amount_kobo": 500000,
"intent_id": "qri_4hJkL5mNpQ",
"completed_at": "2025-06-01T12:05:00Z"
},
"timestamp": "2025-06-01T12:05:01Z",
"webhook_id": "wh_7bCdE8fGhI"
}
Next steps
- Split Payments → — Route marketplace fees in a single atomic transaction.
- Webhooks Overview → — Reliable delivery, retries, and signature verification.
- USSD Payments → — The equivalent flow for users without smartphones.