Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.paybridgenp.com/llms.txt

Use this file to discover all available pages before exploring further.

The Direct-QR API skips the PayBridgeNP hosted checkout entirely. You collect the customer’s details on your own page, call one endpoint, and get back a Fonepay QR string + image plus a real-time event stream you subscribe to from the browser. When the customer pays, you receive qr.paid over Server-Sent Events at the same moment the payment.succeeded webhook fires on your server.
Direct-QR is a Premium feature. On the free plan, POST /v1/qr/fonepay returns 403 with entitlement: "fonepay.directQr".

When to use this

Use Direct-QR when you want full control over the checkout UI - for example, a custom mobile app, a self-built point-of-sale terminal, or a marketplace where you want the QR embedded directly inside your product page rather than after a redirect. For most websites, the standard hosted checkout is simpler and gives you all three providers (eSewa, Khalti, Fonepay) in one flow. Direct-QR is Fonepay-only.

How it works

1

Your server collects customer details

Name, email, phone, and (optionally) address - you own the form.
2

Your server calls POST /v1/qr/fonepay

PayBridgeNP creates a checkout session, talks to Fonepay, and returns a QR string + a base64 PNG + a per-session event stream URL.
3

Your page shows the QR and subscribes to events

Use any QR renderer (or the bundled PNG) and open an EventSource on events_url.
4

Customer scans and pays

You receive qr.scanned the moment the QR opens in their bank app, then qr.paid when the transfer completes - both pushed live, no polling.

Endpoint

POST /v1/qr/fonepay

Authenticated with a secret API key. Idempotency is supported via the standard Idempotency-Key header. Request
{
  "amount": 10000,
  "currency": "NPR",
  "customer": {
    "name": "Ram Sharma",
    "email": "ram@example.com",
    "phone": "9800000000",
    "address": { "line1": "Thamel-12", "city": "Kathmandu" }
  },
  "metadata": { "order_id": "ORD-123" }
}
FieldRequiredNotes
amountYesInteger paisa. Min 1,000 (Rs. 10), max 100,000,000 (Rs. 10,00,000).
currencyNoDefaults to "NPR". Fonepay only supports NPR.
customer.nameYesUp to 100 chars.
customer.emailYesValid email. Used for the receipt.
customer.phoneNoUp to 30 chars. Used for the success SMS on Premium.
customer.addressNoIf present, line1 and city are required.
metadataNoArbitrary JSON object echoed back on webhooks.
Response (201)
{
  "id": "cs_vOKk570ctWssNmFPcFiGfCnM",
  "amount": 10000,
  "currency": "NPR",
  "provider": "fonepay",
  "status": "initiated",
  "qr_message": "00020101021226580011fonepay.com...",
  "qr_image": "data:image/png;base64,iVBORw0KGgo...",
  "events_url": "https://api.paybridgenp.com/v1/qr/cs_vOKk.../events",
  "expires_at": "2026-04-25T00:25:05.347Z"
}
FieldDescription
idSession ID. Doubles as the events_url token - anyone with this ID can listen to events for it, like Stripe’s client_secret.
qr_messageThe raw EMV QR payload. Render it yourself with any QR library if you want full control over size, colors, or to add a logo in the middle.
qr_imageA 320×320 PNG embedded as a data URL. Drop this straight into an <img src> if you don’t want to render your own.
events_urlOpen an EventSource on this to receive qr.scanned, qr.paid, qr.expired, and ping events.
expires_atHard 3-minute deadline imposed by Fonepay. After this, the QR is dead and qr.expired fires.

GET /v1/qr/:id/events

A Server-Sent Events stream. No API key required - the session ID in the URL is the auth token, so this is safe to call directly from a customer’s browser.
EventWhen it firesPayload
connectedRight after the stream opens.{ id, status }
qr.scannedThe customer opened the QR in their bank app.{ sessionId, scannedAt }
qr.paidPayment confirmed by Fonepay. Terminal - the stream closes after this.{ paymentId, sessionId, amount, currency, provider, traceId }
qr.expiredThe 3-minute QR window lapsed without payment. Terminal.{ sessionId, expiredAt }
pingKeepalive every 7s. Ignore it.{}
Replay on reconnect: if the connection drops and the client reconnects after a terminal event already fired, the stream replays it once with replay: true set. So you can safely use the browser’s auto-reconnect without missing the payment.

Full example

<form id="checkout-form">
  <input name="name" placeholder="Full name" required />
  <input name="email" type="email" required />
  <input name="phone" placeholder="98XXXXXXXX" />
  <button type="submit">Pay Rs 100</button>
</form>

<div id="qr-panel" hidden>
  <img id="qr-img" />
  <p id="status">Waiting for scan…</p>
</div>

<script>
const form = document.getElementById("checkout-form");
form.addEventListener("submit", async (e) => {
  e.preventDefault();
  const fd = new FormData(form);

  // Call YOUR server, not PayBridge directly - the API key must stay private
  const res = await fetch("/api/start-payment", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      amount: 10000,                       // Rs 100 in paisa
      customer: {
        name: fd.get("name"),
        email: fd.get("email"),
        phone: fd.get("phone"),
      },
    }),
  });
  const data = await res.json();

  document.getElementById("qr-img").src = data.qr_image;
  document.getElementById("qr-panel").hidden = false;

  const es = new EventSource(data.events_url);
  es.addEventListener("qr.scanned", () => {
    document.getElementById("status").textContent = "Scanned - confirm in your bank app";
  });
  es.addEventListener("qr.paid", (ev) => {
    document.getElementById("status").textContent = "Paid!";
    es.close();
    window.location.href = "/thank-you?p=" + JSON.parse(ev.data).paymentId;
  });
  es.addEventListener("qr.expired", () => {
    document.getElementById("status").textContent = "QR expired - click to retry.";
    es.close();
  });
});
</script>
Server side (Node/Bun example):
// /api/start-payment
import { Hono } from "hono";
const app = new Hono();

app.post("/api/start-payment", async (c) => {
  const body = await c.req.json();
  const r = await fetch("https://api.paybridgenp.com/v1/qr/fonepay", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.PAYBRIDGE_SECRET_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(body),
  });
  return c.json(await r.json(), r.status);
});

Webhooks vs SSE - use both

The SSE stream is for the customer’s browser: instant UI feedback. The payment.succeeded webhook is for your server: durable, signed, retried. They fire from the same event, so use SSE to update the page and the webhook to commit your order to the database. If the customer closes the browser between scan and confirmation, the SSE stream goes away - but the webhook still fires.

Expiry and retries

Fonepay QRs expire after 3 minutes. There is no auto-rotation - when qr.expired fires, the session is permanently dead. To retry, just call POST /v1/qr/fonepay again with the same customer details and you get a fresh QR. This matches how PaymentIntent works in other payment APIs: short-lived intent, simple recreate-on-expiry.

Limitations

  • Fonepay only. Other providers don’t have an equivalent QR-with-realtime-confirmation flow exposed today.
  • NPR only.
  • No partial captures or holds - the QR is a one-shot full-amount transfer.
  • No refunds via this endpoint - refund a successful Direct-QR payment the same way as any other Fonepay payment (create-refund).