Skip to content

POS retail flow

For products that take retail payments at a physical or virtual POS — cashier rings up an amount, buyer scans / pays, cashier sees “Paid” within seconds. The canonical posz integration.

1. Cashier rings up amount.
2. POS calls POST /v1/payments with currency=IDR method=qris.
3. POS displays the QR (either iframe checkout_url, or render qr_string locally).
4. Buyer scans with their wallet, pays.
5. Wallet → NICEPAY → gateway webhook → product's webhook receiver.
6. POS sees "Paid" and prints receipt.

Time from amount-entered to “Paid” on the cashier display: typically 2-8 seconds. The bottleneck is the buyer scanning + the NICEPAY → gateway → product webhook chain.

The POS code should handle BOTH response shapes from POST /v1/payments:

const r = await createPayment(...);
if (r.qr_string) {
// Native rendering — operator has flipped on V2 Direct.
// Render qr_string with any QR library (qrcode.js, etc.).
// Faster UX, no NICEPAY page in the flow.
renderQRLocally(r.qr_string);
} else {
// V2 Checkout (default) — iframe / popup / webview the URL.
// NICEPAY's hosted page renders the QR.
showCheckoutURL(r.checkout_url);
}

This way your POS code works regardless of whether NICEPAY has activated Direct API on the merchant.

See IDR via NICEPAY for the V2 Checkout vs V2 Direct background.

For the cashier to see “Paid” within a second of the buyer paying, you have two options:

  • POS device runs a tiny HTTP server that the gateway POSTs to.
  • Gateway delivers payment.succeeded to your endpoint within ~1s of NICEPAY confirming.
  • Push the result to the cashier display via websocket / SSE / polling-1Hz from the POS device.

This requires the POS to be reachable from the internet OR for your product to have a per-cashier session that proxies through your central gateway-webhook receiver.

  • POS calls GET /v1/payments/{id} every 1s while waiting.
  • Stops when status becomes terminal.
  • Simpler — POS doesn’t need to be reachable from the internet.

Polling is fine for retail — buyers expect a moment of “let me check” after scanning. The 1s poll interval gives sub-second-feeling perception.

For sub-second-actual feel, use webhooks.

QRIS expires in 20 minutes (NICEPAY hard cap). Behavior:

  • If the buyer hasn’t paid within 20 min, the QR becomes invalid even if scanned.
  • Polling GET /v1/payments/{id} after expiry returns status=expired (NICEPAY-side), gateway maps to failed on the canonical event.
  • POS should void the transaction and let the cashier issue a fresh request.

Don’t try to extend the expiry past 20 min — NICEPAY rejects with 9031 Date/Time check.

Receipt should include the gateway’s invoice_number (the human-readable form like POS-000042), not the 32-char hex payment_id. Customers and ops both prefer it for support tickets / refund lookups.

Uncle-Z Coffee Shop
Order POS-000042
Total: Rp 50,000
Paid: 2026-05-09 14:30 WIB via QRIS
If you need help: support@uncle-z.com
reference: POS-000042

Buyer wants their money back? Cashier triggers POST /v1/refunds from the POS. The gateway issues the refund through NICEPAY’s cancel API.

For QRIS specifically:

  • On-Us refunds (same wallet that paid): immediate, free, within 1 day of the original transaction. After 1 day, becomes a manual refund (NICEPAY ops involvement).
  • Off-Us refunds (cross-wallet): online refund supported up to 365 days from original transaction.

The gateway exposes both as POST /v1/refunds — the underlying NICEPAY cancel call handles the on-us / off-us distinction transparently. See Refunds.

POS devices in retail typically have flaky WiFi. The gateway’s idempotency support is your friend:

  • Generate an Idempotency-Key UUID per cashier transaction.
  • Persist it on the POS device alongside the transaction id.
  • Retry the POST /v1/payments call with the SAME key if the network drops mid-call. The gateway recognizes the replay and returns the original response.

See Idempotency.

posz lives at /Volumes/ZT9/codes/posz/ (uncle-z’s reference POS implementation). Pattern:

  • Cashier app (Tauri / Electron) on the device.
  • Backend service (Go / Node — pick one) running at pos.uncle-z.com that:
    • Receives webhooks from payment.uncle-z.com.
    • Holds open websocket per cashier device.
    • Pushes payment.succeeded events down to the matching cashier in real time.
  • Product app keys ((pk_, secret, webhook_secret)) stored on the backend, not the cashier device.

This indirection means cashier devices don’t carry payment credentials and don’t need to be internet-reachable. Same pattern recommended for any retail POS integration.