Skip to main content

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:

  1. Merchant side — your server creates a payment intent for a specific amount and gets back a QR payload to render as an image.
  2. 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"
}
FieldMeaning
pcIdentifies this as a PayKore QR payload (lets wallet apps route correctly).
idThe intent ID — what the payer app submits back.
amtAmount in kobo, for display before confirmation.
refYour 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"
}
}
note

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:

  1. Customer taps "Pay with QR" in your merchant-facing app.
  2. Your server calls POST /v1/payments/qr/intent for the order total.
  3. Your app renders the returned qr_payload as a QR code on screen.
  4. The customer opens their wallet app and scans the code.
  5. Their app decodes the payload, shows them the amount and your merchant reference, and asks for confirmation.
  6. On confirmation, their app calls POST /v1/payments/qr/pay.
  7. PayKore processes the transfer and fires transaction.completed to your registered webhook.
  8. Your server receives the webhook, matches it by reference, and marks the order as paid.
  9. 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