@zopay/js
v0.2.0
Published
Browser SDK for ZoPay. Renders the payment widget (QR + currency/network selector + polling) for a payment intent created by the merchant's backend. Keyless by design.
Maintainers
Readme
@zopay/js
Browser SDK for ZoPay. Renders a payment widget (QR + currency/network selector + status polling) for an intent created by your backend.
Keyless. This package never holds, accepts, or transmits a ZoPay API key. Secret keys (
sk_test_…/sk_live_…) belong on your server. SeeSECURITY.mdfor the threat model.
How ZoPay payments work
[user clicks pay on me.com]
│
▼
[your frontend] ─── POST /your-backend/checkout/session ──▶ [your backend]
│
(holds sk_live_) │
▼
[your backend] ─── POST https://api.zopay.cash/connect/v1/payment-intents ──▶ [ZoPay API]
(amount computed server-side from the cart)
◀── { id, amount, currency, addresses[…] } ──────────────────┘
│
▼
[your frontend] ─── receives intent ──▶ mountPaymentWidget(target, { intent, checkStatus })
(widget shows QR, polls status)The merchant's backend creates the intent. The widget consumes the result.
Install
npm install @zopay/js@zopay/js is keyless and has no peer dependencies you must install.
(Optional React peer is declared but not required.)
Quick start — widget
import { mountPaymentWidget, normalizeIntent } from "@zopay/js";
import "@zopay/js/styles.css";
// 1. Hit a route YOU build on YOUR backend (the `/api/zopay/checkout/session`
// path is illustrative — name it whatever you want). That route holds the
// sk_live_ key, mints a fresh Idempotency-Key per call, and computes the
// amount from the cart server-side. Don't let the browser tell you what
// to charge.
const intent = await fetch("/api/zopay/checkout/session", { method: "POST" })
.then(r => r.json());
// 2. Mount the widget. It renders QR + selector + polling, and calls back
// when state changes. The checkStatus callback proxies status reads
// through YOUR backend — the widget never talks to the ZoPay API itself.
const handle = mountPaymentWidget(document.getElementById("pay")!, {
intent,
checkStatus: async () => {
// Your backend forwards GET /payment-intents/{id} with the secret key
// and returns the response body verbatim. normalizeIntent maps the
// API's PaymentIntent envelope to the StatusInfo shape the widget
// expects — handles deposit field threading, unknown-status fallback,
// and the pending/completed/failed/expired mapping for you.
const res = await fetch(`/api/zopay/status/${intent.id}`);
return normalizeIntent(await res.json());
},
onSuccess: (info) => console.log("paid", info.txHash, info.amountReceived),
onError: (info) => console.error("failed", info),
onExpire: () => console.warn("timed out; manual check still available"),
theme: "light",
});
// React/Vue: clean up on unmount.
// useEffect(() => () => handle.destroy(), []);
// Anywhere — trigger an immediate status check.
// handle.refresh();mountPaymentWidget returns { destroy, refresh }. target accepts either an
element-id string or a DOM node — pass ref.current directly from React.
Quick start — poller only
If you want the polling state machine without the rendered widget (e.g. a custom UI), use the underlying poller:
import { createPoller } from "@zopay/js";
const poller = createPoller({
checkStatus: async () => { /* same as above */ },
pollIntervalMs: 3000,
timeoutMs: 15 * 60 * 1000,
onSuccess: (info) => { /* … */ },
});
poller.start();
// poller.stop(), poller.refresh(), poller.getPhase(), poller.getLastStatus()⚠️ Things you must not do
These are not stylistic preferences — they are the security model.
- Never put a
sk_test_…orsk_live_…key in browser code or in any example committed to git. - Never create a payment intent from the browser. Intent creation needs the secret key. It happens on your backend.
- Never trust an
amountsent from the client. Compute it from the cart on your backend.
A copy-pasteable insecure example is a production breach. See
SECURITY.md.
Sandbox vs live
Single host: https://api.zopay.cash. The key prefix decides the mode:
sk_test_…→ sandbox (fake money).sk_live_…→ production (real money).
Your backend swaps the key; nothing in @zopay/js changes.
