Payments Engine

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

Release limitation — do not send funds. Deposit previews use synthetic addresses with no spendable private key. Live checkout creation returns 503; preview QR codes and wallet submission are disabled. Live server-side APIs require a separate bearer access token. Browser sign-in, durable checkout recovery and token-contract routing remain release blockers.

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.

WidgetMount withWhat the customer does
DepositPaymentsEngine.deposit(el, { account, assets })Chooses an asset and network, scans or copies the address, watches it confirm.
WithdrawalPaymentsEngine.withdraw(el, { token })Checks the address and the amount your server decided, confirms, watches it leave.
The asymmetry is deliberate. A deposit widget may open its own session: the worst a stranger can do with one is create an address nobody pays into. A withdrawal moves money, so it is mounted with a token your server obtained after it checked the account, the limits and the balance. The widget cannot change the amount, the destination or the account, because none of them come from the page.

The deposit widget

your page
<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 frame resizes itself. It reports its height to your page as the content changes — a confirming payment is taller than a waiting one — so you do not have to guess a height or leave a gap.

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.

your server
// 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 }
your page
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.

Confirming twice is safe and is meant to be. The intent carries your idempotency key, so a customer who loses their connection and reloads confirms the same payout rather than a second one.
FieldRequiredNotes
accountRefYesUp to 64 safe characters. Yours; it is what ties the payout to a customer in your books.
assetCodeYesUppercase. Decimals follow the asset.
amountYesA decimal string in display units. Converted to minor units on the string, never through a number.
destinationYesChecked for shape, not for ownership. Only the chain can say whether an address exists, and a typo that passes is still yours to catch.
chainIdNo — ethereumMust be a chain the gateway is configured for.
idempotencyKeyNo — generatedSupply 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

your page
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));
Do not credit a customer from this event. It comes from a page in the customer's browser, and anything in a browser can be forged. Credit from the signed webhook, which arrives at your server and carries a signature you verify. The event is for the interface: close a modal, show a tick, stop a spinner.

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.

Options

OptionDefaultNotes
account—Required unless a token is supplied. Your account reference; it appears on every credited transfer.
modedepositwithdraw for the payout widget. PaymentsEngine.deposit and .withdraw set it for you.
assetsUSDT, BTC, ETHDeposit 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.
chainethereumMust be a chain the gateway is configured for.
amountanyA decimal string in display units. Omit for an open-ended deposit.
themelightdark for a dark surround.
height560Initial height in pixels. The frame reports its own afterwards.
ttlSeconds900Server-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 token is safe to expose. It reads one checkout's state. It cannot reach another account, another endpoint, or an operator credential — which is the property that makes a public payment page acceptable at all.

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.

what the button does
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)
}] });
A wallet payment is still only a submitted transaction. The checkout moves to "sent" and then waits for the chain exactly as a scanned payment does. Nothing is credited until the engine sees the required confirmations, because a submitted transaction can still be dropped or replaced.

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.