QR Code in the payment response
Every POST /v1/payments (and the Ganap-compat create-checkout) returns a QR payload directly in the response. You do not need to redirect to a hosted checkout — you can render the QR on your own page, in your own app, with your own branding.
The three QR fields
The response contains three related fields. Pick the one that fits your UI:
| Field | Shape | When to use it |
|---|---|---|
qr_code | Raw EMVCo payload — a plain string starting with 000201, ~250 chars. | You want full control. Feed into any QR library and render your own image at any size, color, with your logo in the middle, etc. |
qr_code_url | HTTPS URL to a pre-rendered PNG hosted on the provider's CDN. | Fastest path — just embed as <img src>. No dependency on a QR library. |
app_link | Wallet deep link (e.g. gcash://…). May be null. | Mobile web / mobile app flows. Offer a one-tap button that opens the payer's GCash app directly. |
All three are populated for gcash_qr payments in live mode. In test modeqr_code_url points at a placeholder — use Dev Controls on the hosted checkout to drive outcomes (see Testing & Sandbox).
Example response
{
"id": "pay_0e1f2a4c9b8d6a3f5c7e1d2b",
"object": "payment",
"status": "pending",
"amount": 500.00,
"currency": "PHP",
"method": "gcash_qr",
"checkout_url": "https://nexuspayph.com/checkout/pay_0e1f2a4c9b8d6a3f5c7e1d2b",
"qr_code": "00020101021128680014ph.ppmi.p2m0113...6304ABCD",
"qr_code_url": "https://c.tsdpay.tech/v2/qr/TlVRK0tu...TE9",
"app_link": "gcash://payment/scan?payload=...",
"expires_at": 1776840000,
"created": 1776839700
}Rendering tips by stack
The qr_code string is the EMVCo QR Code Specification for Payment Systems payload — the same shape every GCash / PayMaya / Maya QR uses. Any QR library can render it.
// Using the 'qrcode.react' library — the most popular React QR renderer.
// npm i qrcode.react
import { QRCodeCanvas } from 'qrcode.react';
export function PayQR({ payment }) {
return (
<div className="text-center">
<h3>Scan with your GCash app</h3>
<QRCodeCanvas
value={payment.qr_code}
size={280}
level="M" // error correction
includeMargin
imageSettings={{ // optional centered logo
src: '/your-logo.png',
height: 56,
width: 56,
excavate: true,
}}
/>
<p>Amount: ₱{payment.amount.toFixed(2)}</p>
</div>
);
}UX tips
1. Desktop: show the QR. Mobile: offer the deep link.
Scanning a QR that's on the same phone you need to pay with is awkward — the customer can't scan the screen that's showing the QR. On mobile, prefer app_link:
function PayAction({ payment }) {
const isMobile = typeof navigator !== 'undefined' &&
/iPhone|Android/i.test(navigator.userAgent);
if (isMobile && payment.app_link) {
return <a href={payment.app_link} className="btn">Open in GCash</a>;
}
return <QRCodeCanvas value={payment.qr_code} size={280} />;
}2. Show the countdown to expiry
expires_at is a Unix timestamp (seconds). Convert and render a countdown so customers know when the QR goes stale. Typical checkout window is 10–15 minutes:
import { useEffect, useState } from 'react';
function ExpiresIn({ expiresAt }) {
const [left, setLeft] = useState(() => expiresAt * 1000 - Date.now());
useEffect(() => {
const id = setInterval(() => setLeft(expiresAt * 1000 - Date.now()), 1000);
return () => clearInterval(id);
}, [expiresAt]);
if (left <= 0) return <span>Expired — please create a new payment.</span>;
const m = Math.floor(left / 60000), s = Math.floor((left % 60000) / 1000);
return <span>Expires in {m}:{String(s).padStart(2, '0')}</span>;
}3. Poll status, or just listen for the webhook
Once the QR is on screen, you need to know when the customer paid. Two options:
- Webhook (recommended) — register an endpoint and NexusPay POSTs
payment.succeeded/payment.failed/payment.expired. Update your UI via websocket or server-sent events from there. See Webhooks. - Polling — hit
GET /v1/payments/{id}every 5 seconds while the checkout is on screen. Stop oncestatus !== 'pending'orexpires_atpasses.
4. Don't regenerate the QR unnecessarily
qr_code is stable for the lifetime of the payment — the same value is returned on every GET /v1/payments/{id}. Create one payment, render once, update UI on status change.
5. Idempotency: replays are free
If your merchant backend retries a POST /v1/payments with the same Idempotency-Key, NexusPay returns the existing payment — same qr_code, same qr_code_url. Your customer doesn't see a new QR. See Idempotency.
Troubleshooting
qr_code is null in my response
- Test mode never returns a real QR — use the hosted
checkout_url. - Non-QR methods (e.g. card_3ds in the future) won't populate QR fields. Check
methodisgcash_qr. - Upstream provider occasionally fails to return QR data. In that case
checkout_urlstill works — NexusPay's own branded checkout fetches the QR itself.
Scan works in Camera but not in GCash
GCash's scanner only accepts EMVCo P2M payloads. Our qr_code is exactly that. If your generated image doesn't scan in GCash, double-check you rendered the raw qr_code string — not a shortened / URL-wrapped version.
My QR library complains about encoding
EMVCo payloads are ASCII-only. If you see encoding errors, make sure you're passing the raw string without any transformation. Most libraries default to error correction: M, which is what GCash expects.
Just redirect to checkout_url. You get NexusPay-branded QR, countdown, retry UX, mobile deep link button — all maintained by us. You can always switch to rendering your own later; the API is the same.