Pay by bank in three steps.
Your server creates a payment, the customer approves at their bank through our button or hosted page, and you fulfil the order when our signed webhook arrives. The customer's bank pays your account directly.
Six terms to know first.
| Term | What it means |
|---|---|
| Minor units | Money is always an integer of the smallest unit plus an ISO 4217 currency. 4999 GBP is £49.99. Never send floats such as 49.99. |
| API key (sk_…) | A server-only secret that can create payments. It must never reach a browser. |
| Payment id (pay_…) | Identifies one payment and works as a bearer token for its checkout. Treat it like a one-time link: keep it out of public logs and analytics. |
| Idempotency-Key | A value you choose per payment, such as your order id. Sending it twice returns the same payment instead of a duplicate. Required when creating. |
| SCA | Strong customer authentication: the payer approving in their banking app. It happens on the bank's screen, not yours or ours. |
| Webhook | A signed POST we send your server when a payment finishes. This, not anything in the browser, is what you trust to fulfil an order. |
Same backend. Pick the front end.
You control the UI
Create the payment, then send the customer to the hosted checkout at /pay/{id}. If you already know their bank, pass institutionId and redirect straight to the authorisationUrl.
No payment UI to build
Add <pay-button> with pay.js. It calls your create endpoint, opens the checkout and lets the customer choose their bank, so you never manage a bank list.
Either way, fulfil on the webhook and treat the browser result as a hint for the thank-you screen.
The typical integration.
Flow B with the customer choosing their bank on our page. This is the whole happy path; the sections below cover fields and edge cases.
- Create endpoint on your server
Look up the real amount from your own order, call ZyroPay with your API key and return only the payment id.
- Button on your page
Point
create-urlat that endpoint and addresumeso the flow completes after the bank redirect. - Webhook handler
Verify the signature on the raw body, acknowledge with 200, then fulfil once per
paymentId.
const API = "https://pay.zyropay.net";
app.post("/api/checkout", express.json(), async (req, res) => {
const orderId = req.body.orderId;
const order = await db.getOrder(orderId); // never trust a browser price
const r = await fetch(`${API}/api/merchant/payment/requests`, {
method: "POST",
headers: {
"x-api-key": process.env.ZYROPAY_API_KEY,
"Idempotency-Key": orderId,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: { minorUnits: order.minorUnits, currency: order.currency },
reference: orderId,
}),
});
if (!r.ok) return res.status(502).json({ error: "create_failed" });
const payment = await r.json();
res.json({ id: payment.id });
});
<script src="https://pay.zyropay.net/pay.js"></script>
<pay-button create-url="/api/checkout"
amount="49.99" currency="GBP" ref="ORDER-1001" resume>
</pay-button>
import { createHmac, timingSafeEqual } from "node:crypto";
app.post("/webhooks/zyropay", express.raw({ type: "application/json" }), async (req, res) => {
if (!verify(req.body, req.get("x-openpay-signature"), process.env.ZYROPAY_WEBHOOK_SECRET))
return res.sendStatus(401);
const evt = JSON.parse(req.body.toString("utf8"));
res.sendStatus(200);
if (evt.event === "payment.completed") await db.fulfilOnce(evt.paymentId, evt.reference);
else if (evt.event === "payment.rejected" || evt.event === "payment.failed") await db.markUnpaid(evt.reference);
});
function verify(rawBody, header, secret) {
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
const got = (header || "").replace(/^sha256=/, "");
const a = Buffer.from(got), b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
Endpoints and authentication.
Send your API key in the x-api-key header (Authorization: Bearer sk_… also works). It's shown once when your account is provisioned; keep it in a secret manager and rotate it if it ever appears in front-end code, a network log or a commit. Reads are scoped to your own payments.
| Endpoint | What it does |
|---|---|
| POST/api/merchant/payment/requests | Create a payment. Returns 201 with the payment, status CREATED (customer picks bank) or AWAITING_AUTHORISATION (bank pre-selected). |
| GET/api/merchant/payment/requests/{id} | Read one payment. |
| GET/api/merchant/payment/requests | List payments. Filter by status and type, page with limit and offset, sort by createdAt or updatedAt. Returns { data, total, limit, offset }. |
Create request body
| Field | Rules and notes |
|---|---|
| amount.minorUnits | Required. Integer ≥ 0. 4999 is £49.99. |
| amount.currency | Required. Three upper-case letters, ISO 4217, for example GBP. |
| reference | Required. 1 to 140 characters. Shown on the payer's bank statement. |
| institutionId | Optional. The payer's bank. Leave it out to let the payer choose on our checkout (recommended). |
| type | Optional. Any of DOMESTIC, INSTANT; default ["DOMESTIC"]. Send both to use the instant rail whenever the payer's bank supports it, with standard as the fallback. |
| payer | Optional. The customer's own account, as { name, iban } or { name, sortCode, accountNumber }. Only some countries need it, not the UK. Never echoed back. |
There is no payee field. Money always settles to the account registered for you at onboarding, so a leaked key can't redirect funds. Same Idempotency-Key with the same body returns the existing payment; with a different body it returns 409 IDEMPOTENCY_CONFLICT.
Eight statuses, four of them final.
A webhook fires on every final status.
| Status | Meaning | Final |
|---|---|---|
| CREATED | Exists, waiting for the customer to choose a bank. | No |
| AWAITING_AUTHORISATION | Bank chosen; the customer needs to approve. | No |
| AUTHORISED | Customer approved; we're executing the payment. | No |
| SUBMITTED | Sent to the bank for settlement. | No |
| COMPLETED | Money is moving to you. Fulfil now. | Yes |
| REJECTED | The customer declined at their bank. | Yes |
| FAILED | Timed out, or the bank returned an error. | Yes |
| CANCELLED | The customer cancelled before choosing a bank, or didn't choose one within about 60 minutes. | Yes |
<pay-button> reference.
Load https://pay.zyropay.net/pay.js. Use payment-id for a payment your server already created, or create-url to have the button ask your backend for one on click. The amount attribute is a display decimal; your server still sends minor units.
| Attribute | What it does |
|---|---|
| payment-id | A pre-created pay_… id. Use this or create-url. |
| create-url | Your endpoint. The button POSTs { amount, currency, ref } and expects { id }. |
| amount · currency · ref | Shown on the button and sent to create-url. Currency defaults to GBP. |
| label | Overrides the button text. |
| base-url | ZyroPay origin. Detected from the script's src if left out. |
| auto-open | Opens the checkout on page load without a click. |
| resume | Reconnects to an in-flight payment when the customer returns from their bank. Needs a registered return URL. |
For the screen, not the stockroom
payment:settled, payment:cancelled and payment:error bubble from the element. Use them to show a spinner or a thank-you message. Never fulfil from them: a browser can be closed, scripted or spoofed.
What the button remembers
Only localStorage["openpay:pending"] with the payment id and a timestamp. It's written when the checkout opens, cleared on any final outcome and expires after 30 minutes. No personal or payment data.
Register where you embed
The checkout can only be framed on the domains registered for your account, enforced with a CSP frame-ancestors rule. Register every domain that hosts the button.
Where the customer lands
After the bank step we send the browser to your registered returnUrl with ?payment=<id>&status=<status> appended, or to our result page if none is set. Confirm with the API or webhook before treating an order as paid.
Your source of truth.
When a payment reaches a final status we POST JSON to your registered webhookUrl, with an x-openpay-event header and an x-openpay-signature of sha256=<hex HMAC of the raw body>, signed with the secret issued to you at onboarding.
| Event | Sent when |
|---|---|
| payment.completed | Money is moving to you. Fulfil the order. |
| payment.rejected | The customer declined at their bank. |
| payment.failed | Timeout or bank error. |
| payment.cancelled | Cancelled before a bank was chosen, or abandoned. |
- Hash the exact bytes. Capture the raw body; re-serialised JSON won't match the signature. Compare in constant time.
- Expect repeats. Failed deliveries retry with exponential backoff until you return 2xx, so make fulfilment idempotent on
paymentId. - Acknowledge first. Return 2xx quickly and send emails or ship orders afterwards.
What can go wrong, and the fix.
The merchant API allows 120 requests a minute per key; public checkout endpoints allow 600 a minute per IP. Over the limit you get 429 with RateLimit-* headers. Errors are JSON: { "error": "CODE", "message": "…" }.
| Status · code | Meaning and fix |
|---|---|
| 400 INVALID_JSON | The body isn't valid JSON. |
| 400 MISSING_IDEMPOTENCY_KEY | Add the Idempotency-Key header. |
| 401 UNAUTHORIZED | Missing or wrong API key. |
| 404 PAYMENT_NOT_FOUND | Unknown id, or one that belongs to another merchant. |
| 409 IDEMPOTENCY_CONFLICT | Key reused with a different body. Use a new key per order. |
| 422 VALIDATION | The body failed validation, for example a float amount, a bad currency or a missing reference. |
| 429 RATE_LIMITED | Slow down and retry after the window. Your idempotency key makes retries safe. |
Test every path before real money moves.
The sandbox demo embeds pay.js exactly as you would, calls a stand-in backend and logs every payment:* event, with resume switched on so you can watch the round trip.
- Stub mode runs the whole flow without bank credentials, so you can test your webhook handler end to end.
- Test the unhappy paths. Cancel at the bank to get
payment.rejected, and send the same webhook twice to prove fulfilment only happens once. - Use a fresh idempotency key per test so retries don't replay an old payment.
- API key only in server config, never in a page or repository.
- Prices calculated on your server.
- One
Idempotency-Keyper order. - Webhook signature verified on the raw body, in constant time.
- Fulfilment idempotent on
paymentId. - Browser events and
?status=used for UX only. - Embed domains and return URL registered.
- Pages served over HTTPS.