Skip to main content

Payment button

An embeddable component for your own checkout page. The customer never leaves your site; the payment UI runs in a cross-origin iframe served from ChainOS.

Configure your webhook first. Verifying payments explains why in one page. Whatever is in your quickstart is what reaches production.

1. Create the session on your server

// Your server. The secret key never reaches a browser.
const res = await fetch(`${CHAINOS}/v1/payments/sessions`, {
method: 'POST',
headers: {
'X-API-Key': process.env.CHAINOS_PAYMENT_SECRET, // pmk_live_…
'Content-Type': 'application/json',
// Derivation burns an index on your extended public key that is never
// reused. A retried POST without this costs you an address you cannot
// get back.
'Idempotency-Key': `order-${order.id}`,
},
body: JSON.stringify({
chain: 'base',
asset: 'USDC',
amount: order.total, // a STRING: "49.99"
reference: order.id, // your own id, echoed on every event
description: `Order ${order.id}`,
customerEmail: order.email,
expiresInSeconds: 900,
}),
});

const { data: session } = await res.json();
// session.reference → "pay_7YQ2M4KDX"
// session.clientSecret → shown once, on this response and no other

Hand reference and clientSecret to your page. The client secret is what the payer's browser presents to read the session; it is not a credential for your account and it can do nothing else.

2. Open the checkout

React

npm install @ziklag/chainos-react
import { CryptoPayment } from '@ziklag/chainos-react';

export default function Checkout({ session }) {
return (
<CryptoPayment
reference={session.reference}
clientSecret={session.clientSecret}
host="https://pay.your-company.example"
onSuccess={(hint) => router.push(`/thanks?ref=${hint.reference}`)}
onDismiss={(reason) => console.log('closed:', reason)}
>
Pay {session.amount} {session.asset}
</CryptoPayment>
);
}

The button is deliberately minimal — pass a className and you get your styles and none of ours, because a button that is almost your design is worse than either. For your own markup entirely, use the hook:

import { useCryptoPayment } from '@ziklag/chainos-react';

const { openCheckout, isOpen } = useCryptoPayment({
reference: session.reference,
clientSecret: session.clientSecret,
onSuccess: (hint) => router.push(`/thanks?ref=${hint.reference}`),
});

return <YourButton onClick={openCheckout} busy={isOpen}>Pay with crypto</YourButton>;

Plain JavaScript

npm install @ziklag/chainos-js
import * as ChainOSPay from '@ziklag/chainos-js';

document.querySelector('#pay').addEventListener('click', () => {
ChainOSPay.open({
reference: session.reference,
clientSecret: session.clientSecret,
host: 'https://pay.your-company.example',
onSuccess: (hint) => showThanks(hint.reference),
});
});

Or from a script tag, where it is available as ChainOSPay:

<script src="https://unpkg.com/@ziklag/chainos-js"></script>

host is not optional in practice

Every ChainOS deployment is a separate organization running its own Edge, so there is no single CDN origin that is correct for everyone. host defaults to the hosted service; if you run your own, pass yours. Getting it wrong produces a checkout that cannot reach your API, not a checkout that silently pays somebody else.

3. Fulfil from the webhook

Covered in Verifying payments. The one-line version: onSuccess is for your user interface, the signed payment_confirmed webhook is for your order book.

Publishable-key mode

For a page with no server session — a donate button, a tip jar, a fixed-price digital item:

<CryptoPayment
publishableKey="ppk_live_…"
chain="base"
asset="USDC"
amount="5.00"
onSuccess={(hint) => setDone(hint.reference)}
/>

The key is origin-locked to the exact origins you registered when you minted it, and capped per session. Both are on the key, not on the call, so a key lifted out of your page source does not work from somebody else's.

The browser asserts the amount

There is no way around this and you should not integrate as though there were. A customer can pay 0.01 for a 100.00 item, and the webhook will faithfully report a confirmed payment carrying amountWasClientAsserted: true.

Use publishable-key mode where the amount genuinely is the customer's to choose. For a cart total, create the session on your server.

Registering your domains

A publishable key carries an exact origin list — https://shop.example.com, port included if you use one. It drives two things:

  1. The frame-ancestors policy the checkout is served with, so only your pages can frame it.
  2. The Origin the key itself is accepted from.

Origins are compared as whole strings and never by suffix, so https://evil-example.com does not match https://example.com.

They are declared, not verified

Nothing proves you control a host you type in. The exposure is narrow and worth knowing exactly: somebody who registers a domain they do not own can frame a checkout that pays them. It cannot redirect your money, because which address a session settles to is decided on our side and never by the page doing the framing.

Manage them in the portal under API keys → Payment keys, or with PATCH /v1/payments/keys/{id}.

What the payer sees

Down the page, in this order:

  1. The exact amount, as a decimal string, never rounded.
  2. "Send only USDC on Base. Anything else is lost." — above the fold.
  3. The address, in full. Not truncated: the elided middle is where substitution hides and is the part a careful payer checks against their wallet.
  4. A QR code, and a Copy button.
  5. The countdown, and the confirmation meter.

The countdown recomputes from the wall clock every tick rather than decrementing — a decremented counter stops when a mobile tab backgrounds, which is exactly when the payer has switched to their wallet app. It corrects for a device clock that is wrong, and it never declares expiry itself: at zero it says "Checking with the network…" and asks the server, because a fast clock cancelling an already-broadcast payment is the worst outcome available.

Closing the overlay

onDismiss fires with a reason. Escape and a backdrop click are refused while a payment is detected or confirming — watching a customer dismiss a dialog over money they have already sent is a support ticket every time.

Connecting a wallet

QR plus a copyable address is the primary path on every chain, it works today, and nothing below has to succeed for a payment to go through. The connect options sit beneath the address, never above it — a checkout that leads with "Connect wallet" fails completely for the payer whose wallet is on their phone, which is most of them.

On the five EVM chains the payer is also offered:

  • Connect a wallet app — WalletConnect v2, which is the only wallet transport that genuinely works from a cross-origin iframe: a relay WebSocket and a pairing URI, with no injection and no origin permission to negotiate. It needs a project id (below).
  • Use a browser extension wallet — a popup on the ChainOS origin. Injected window.ethereum is window-scoped and origin-permissioned, so in a third-party frame it is absent in some browsers, unusable in others and fine in the rest, varying by wallet and version. A top-level window on our own origin is where extensions behave the way their authors tested them.

Bitcoin and TRON get neither, and that is not a gap waiting to be filled. TRON has no honoured URI scheme and no connection protocol that behaves in a frame; Bitcoin gets BIP-21 in the QR and nothing else. The Connect section renders nothing at all on those two rather than showing a button that opens nothing.

Enabling WalletConnect

Set a WalletConnect Cloud project id on your server:

CHAINOS_PAYMENTS_WALLETCONNECT_PROJECT_ID=…

Leaving it unset is a supported configuration, not a broken one — the checkout simply offers no WalletConnect button. It is not a secret: it identifies the project rather than authenticating it, and every client using the relay carries one.

The connector itself is several hundred kilobytes and is fetched on the click that asks for it, never on the first paint.

What we deliberately do not do

We do not proxy EIP-1193 calls to your page. It is tempting — your page has a working window.ethereum and is one postMessage away — and it would hand your page construction of the transaction's to address, which is the substitution the cross-origin frame exists to prevent. The convenience is real and the answer is still no.

We do not send without switching networks first. The same address exists on every EVM chain, so a wallet left on Ethereum while the checkout expects Base would send real funds to a real address on a network nothing is watching. If the wallet will not switch, the payer is returned to the QR path rather than allowed to send anyway.

We re-read the recipient out of the bytes before a wallet sees them. A token transfer targets the contract with the payee encoded in the call data, while a native transfer targets the payee directly; inverting those sends ETH to a token contract. The builder returns the recipient it encoded and the caller asserts it against the address on screen, so that mistake fails loudly instead of silently.

Content Security Policy

If your site sends a CSP, allow the checkout origin as a frame source:

frame-src https://pay.your-company.example;

Nothing else is needed. The checkout sets no cookies and touches no storage, so ITP, partitioned cookies, SameSite and the Storage Access API are all out of the picture.