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/create \
-H "Authorization: Bearer <sk_test_YOUR_KEY_HERE>" \
-H "Content-Type: application/json" \
-d '{
"merchant_wallet": "25a793b6-ca5a-49d0-b74a-20731426e0eb",
"amount": 500000,
"merchant_ref": "order_789"
}'
Response:
{
"data":{
"expires_at":"2026-09-04T23:08:23.202038492+01:00",
"intent_id":"890e4e1e-ad6a-4936-b76d-8562d94ea3b0",
"payload":"{
\"amount\":500000,
\"expires_at\":1788559703,
\"intent_id\":\"890e4e1e-ad6a-4936-b76d-8562d94ea3b0\",
\"merchant_ref\":\"order_789\",
\"partner_id\":\"fec4e29a-dc37-4857-9383-01924a61070f\"
}"
}
}
The 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 http://localhost:8080/v1/payments/qr/pay \
-H "Authorization: Bearer sk_sandbox_af0065aec6b15ef55ee83617b4efdac84d6a17e120d261fde49b174df7861ea1" \
-H "Content-Type: application/json" \
-d '{
"intent_id": "6c3225fe-cbdf-4c11-8657-a94a042ae24f",
"payer_wallet": "8f47af62-b9eb-4387-827f-ca3903b64450"
}'
Response: successful payment:
{
"data":{
"id":"5c79d472-c29d-402e-a2cc-0184a3f976fb",
"partner_id":"fec4e29a-dc37-4857-9383-01924a61070f",
"reference":"qr-6c3225fe-cbdf-4c11-8657-a94a042ae24f",
"type":"qr_payment",
"status":"completed",
"amount":500000,
"currency":"NGN",
"source_wallet":"8f47af62-b9eb-4387-827f-ca3903b64450",
"dest_wallet":"25a793b6-ca5a-49d0-b74a-20731426e0eb",
"source_account":null,
"dest_account":null,
"fee_breakdown":{
"customerFee":7500,
"merchantFee":0,
"platformFee":6500,
"mfbCost":1000,
"netAmount":492500
},
"split_config":null,
"mfb_reference":"",
"psp_reference":"",
"psp_meta":null,
"idempotency_key":"",
"description":"",
"metadata":{
"intent_id":"6c3225fe-cbdf-4c11-8657-a94a042ae24f",
"merchant_ref":"order_789"
},
"failed_reason":"",
"completed_at":"2026-09-05T09:53:30.592260574+01:00",
"created_at":"2026-09-05T09:53:26.815399+01:00",
"updated_at":"2026-09-05T09:53:26.815399+01:00"
}
}
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.