Embedded widgets
Preview the deposit and withdrawal experience before integrating. These widgets are not a production-ready cashier: live deposit allocation is disabled until custody-backed wallets and end-to-end settlement are connected.
Two widgets, one loader
There is a widget for money coming in and a widget for money going out. They share a loader, a stylesheet and a look, so a customer who trusts one recognises the other.
| Widget | Mount with | What the customer does |
|---|---|---|
| Deposit | PaymentsEngine.deposit(el, { account, assets }) | Chooses an asset and network, scans or copies the address, watches it confirm. |
| Withdrawal | PaymentsEngine.withdraw(el, { token }) | Checks the address and the amount your server decided, confirms, watches it leave. |
The deposit widget
<div id="cashier"></div> <script src="https://engine.example/embed.js"></script> <script> PaymentsEngine.deposit('#cashier', { account: 'account_4471', // your account reference assets: ['USDT', 'BTC', 'ETH'], // what you accept — the customer picks amount: '250.00' // optional — omit for any amount }); </script>
This mounts a local preview, not a live deposit integration. The production flow still needs a custody-backed address allocator, a verified asset/network/contract registry and a running chain watcher. Selecting a token in the UI does not establish support for that token on a network.
Name a single asset and chain instead of assets and the chooser is skipped.
The withdrawal widget
Two steps, and the first one is yours. Your server decides the withdrawal is allowed — that is where KYC, limits, fraud rules and your own ledger live — and opens an intent. The token it gets back is what the widget is mounted with.
// You have already checked the account and debited your own ledger. const intent = await fetch('https://engine.example/api/embed/withdrawal', { method: 'POST', headers: { 'content-type': 'application/json', Authorization: 'Bearer ' + process.env.PAYMENTS_CONSOLE_ACCESS_TOKEN }, body: JSON.stringify({ accountRef: 'account_4471', assetCode: 'USDT', chainId: 'ethereum', amount: '120.00', destination: withdrawal.destination, idempotencyKey: `withdrawal-${withdrawal.id}` }) }).then(r => r.json()); // { token, payoutId, expiresAt, widgetUrl }
PaymentsEngine.withdraw('#cashier', { token: intent.token });
The widget shows the amount, the network and the destination, asks the customer to check the address against their wallet, and sends nothing until they confirm. After that it tracks the payout: requested, signed, broadcast, confirmed, with the transaction hash when there is one.
| Field | Required | Notes |
|---|---|---|
accountRef | Yes | Up to 64 safe characters. Yours; it is what ties the payout to a customer in your books. |
assetCode | Yes | Uppercase. Decimals follow the asset. |
amount | Yes | A decimal string in display units. Converted to minor units on the string, never through a number. |
destination | Yes | Checked for shape, not for ownership. Only the chain can say whether an address exists, and a typo that passes is still yours to catch. |
chainId | No — ethereum | Must be a chain the gateway is configured for. |
idempotencyKey | No — generated | Supply your own withdrawal identifier. The payout ID is derived from it, which is what makes a repeat confirmation land on the same payout. |
Reacting to what happens
const checkout = PaymentsEngine.mount('#cashier', { account: 'account_4471' }); checkout.on('status', (state) => { state.status; // AWAITING_PAYMENT | DETECTED | CONFIRMING | CREDITED | EXPIRED | REORGED state.confirmations; // against state.requiredConfirmations state.receivedMinor; // a decimal string, never a number if (state.status === 'CREDITED') closeModal(); }); checkout.on('wallet_submitted', ({ txHash }) => showPending(txHash)); checkout.on('ready', () => hideSpinner()); checkout.on('*', (type, detail) => console.log(type, detail));
Opening a session from your server
The two-line version opens a session from the browser, which is fine when the account reference is not a secret. When you would rather your server decide, open the session there and mount the returned URL.
const response = await fetch('https://engine.example/api/embed/session', { method: 'POST', headers: { 'content-type': 'application/json', Authorization: 'Bearer ' + process.env.PAYMENTS_CONSOLE_ACCESS_TOKEN }, body: JSON.stringify({ accountRef: 'account_4471', assetCode: 'USDT', chainId: 'ethereum', amount: '250.00', ttlSeconds: 900 }) }); const { token, address, checkoutUrl, expiresAt } = await response.json(); // Hand the token to the page. It reads one checkout's state and nothing else.
<iframe src="https://engine.example/embed/checkout?token=SESSION_TOKEN" style="width:100%;height:560px;border:0" allow="clipboard-write; payment"> </iframe> <!-- Or hand the token to the loader instead of an account: --> <script> PaymentsEngine.mount('#cashier', { token: 'SESSION_TOKEN' }); </script>
// If you would rather render your own interface, the state endpoint is public // for a session token and needs no signature. const state = await (await fetch( `https://engine.example/api/embed/state?token=${token}` )).json(); state.paymentUri; // Tokens: plain address until a verified token contract is configured. state.address; state.status; state.secondsRemaining;
Options
| Option | Default | Notes |
|---|---|---|
account | — | Required unless a token is supplied. Your account reference; it appears on every credited transfer. |
mode | deposit | withdraw for the payout widget. PaymentsEngine.deposit and .withdraw set it for you. |
assets | USDT, BTC, ETH | Deposit only. Which assets this cashier accepts. With more than one and no asset named, the widget asks first. |
asset | — | Deposit only. Naming one skips the chooser. Decimals follow the asset, which is what makes the QR ask for the right amount. |
chain | ethereum | Must be a chain the gateway is configured for. |
amount | any | A decimal string in display units. Omit for an open-ended deposit. |
theme | light | dark for a dark surround. |
height | 560 | Initial height in pixels. The frame reports its own afterwards. |
ttlSeconds | 900 | Server-side only. Clamped to 60–3600. |
Why it is an iframe and not a component
A component rendered into your page inherits your CSS and is readable by your scripts. For a payment surface both are liabilities rather than conveniences:
- Your stylesheet cannot restyle an address. A stray rule that truncates or reflows an address produces a payer who sends to a string that is not quite the address.
- A script on your page cannot rewrite the destination. That includes scripts you did not write — an analytics tag, a chat widget, a compromised dependency.
- The QR is generated on the engine's server and inlined as SVG. Nothing is fetched from a CDN, so there is no third party who could serve a different symbol.
The frame is sandboxed to allow-scripts allow-same-origin allow-popups: enough for a wallet extension and the clipboard, and nothing else. No top-level navigation, no forms, no downloads.
Wallet payments
Wallet submission is disabled in previews and for token payments. The native-ETH example below must not be used for USDT or USDC: a token transfer needs its verified contract address and encoded transfer call, not a native transaction value. Live deposit creation is currently blocked.
const accounts = await ethereum.request({ method: 'eth_requestAccounts' }); if (await ethereum.request({ method: 'eth_chainId' }) !== expected) { await ethereum.request({ method: 'wallet_switchEthereumChain', params: [{ chainId: expected }] }); } await ethereum.request({ method: 'eth_sendTransaction', params: [{ from: accounts[0], to: state.address, // Hex wei via BigInt — a value this size is not representable as a double. value: '0x' + BigInt(state.amountMinor).toString(16) }] });
No payment QR is shown for a preview. Without a verified token contract, the URI helper returns only an address and never encodes a token quantity as ETH. Do not treat that fallback as a complete token-payment instruction.