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.

Concepts

Six terms to know first.

TermWhat it means
Minor unitsMoney 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-KeyA value you choose per payment, such as your order id. Sending it twice returns the same payment instead of a duplicate. Required when creating.
SCAStrong customer authentication: the payer approving in their banking app. It happens on the bank's screen, not yours or ours.
WebhookA signed POST we send your server when a payment finishes. This, not anything in the browser, is what you trust to fulfil an order.
Two ways to integrate

Same backend. Pick the front end.

Flow A · server redirect

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.

Flow B · drop-in button (recommended)

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.

Quick start

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.

  1. 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.

  2. Button on your page

    Point create-url at that endpoint and add resume so the flow completes after the bank redirect.

  3. 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 });
});
API

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.

EndpointWhat it does
POST/api/merchant/payment/requestsCreate 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/requestsList payments. Filter by status and type, page with limit and offset, sort by createdAt or updatedAt. Returns { data, total, limit, offset }.

Create request body

FieldRules and notes
amount.minorUnitsRequired. Integer ≥ 0. 4999 is £49.99.
amount.currencyRequired. Three upper-case letters, ISO 4217, for example GBP.
referenceRequired. 1 to 140 characters. Shown on the payer's bank statement.
institutionIdOptional. The payer's bank. Leave it out to let the payer choose on our checkout (recommended).
typeOptional. 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.
payerOptional. 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.

Payment lifecycle

Eight statuses, four of them final.

A webhook fires on every final status.

StatusMeaningFinal
CREATEDExists, waiting for the customer to choose a bank.No
AWAITING_AUTHORISATIONBank chosen; the customer needs to approve.No
AUTHORISEDCustomer approved; we're executing the payment.No
SUBMITTEDSent to the bank for settlement.No
COMPLETEDMoney is moving to you. Fulfil now.Yes
REJECTEDThe customer declined at their bank.Yes
FAILEDTimed out, or the bank returned an error.Yes
CANCELLEDThe customer cancelled before choosing a bank, or didn't choose one within about 60 minutes.Yes
Drop-in button

<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.

AttributeWhat it does
payment-idA pre-created pay_… id. Use this or create-url.
create-urlYour endpoint. The button POSTs { amount, currency, ref } and expects { id }.
amount · currency · refShown on the button and sent to create-url. Currency defaults to GBP.
labelOverrides the button text.
base-urlZyroPay origin. Detected from the script's src if left out.
auto-openOpens the checkout on page load without a click.
resumeReconnects to an in-flight payment when the customer returns from their bank. Needs a registered return URL.
Events

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.

Resume and storage

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.

Embed domains

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.

Return URL

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.

Webhooks

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.

EventSent when
payment.completedMoney is moving to you. Fulfil the order.
payment.rejectedThe customer declined at their bank.
payment.failedTimeout or bank error.
payment.cancelledCancelled 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.
Limits and errors

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 · codeMeaning and fix
400 INVALID_JSONThe body isn't valid JSON.
400 MISSING_IDEMPOTENCY_KEYAdd the Idempotency-Key header.
401 UNAUTHORIZEDMissing or wrong API key.
404 PAYMENT_NOT_FOUNDUnknown id, or one that belongs to another merchant.
409 IDEMPOTENCY_CONFLICTKey reused with a different body. Use a new key per order.
422 VALIDATIONThe body failed validation, for example a float amount, a bad currency or a missing reference.
429 RATE_LIMITEDSlow down and retry after the window. Your idempotency key makes retries safe.
Sandbox

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.
Security checklist
  • API key only in server config, never in a page or repository.
  • Prices calculated on your server.
  • One Idempotency-Key per 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.