Skip to main content
bankroll.charge() charges the user’s Bankroll balance and settles the funds to the address your manifest fixes in capabilities.payments; the page supplies only the amount.
charge() resolves with the settled payment’s signature (an opaque string) only once the payment has settled, so your server can confirm it the moment the call returns. The promise can stay pending while the user completes the payment — don’t wrap it in a short timeout.
Settlement is final and irreversible — there are no chargebacks. There’s no settlement webhook yet, so reconcile by verifying on-chain and storing each signature, as shown below. If the page never gets to report the signature back to you, a reference is how you find the charge anyway.

Input

Retries and idempotency

idempotencyKey names one logical payment. Generate it when you create the order, store it with the order, and send the same key on every attempt to pay for it.
  • The same key resolves with the same payment’s signature: if the payment already settled, charge() returns its signature without charging again; if it’s still settling, charge() waits for it. At most one charge per key.
  • An attempt that ends without a charge (declined, insufficient funds, error) doesn’t consume the key — the next attempt starts a new charge.
  • Reusing a key with a different amountCents or memo rejects idempotency_conflict.
Keys are scoped to your app and user, and retained for 24 hours.
Without an idempotencyKey, every call is an independent charge — don’t blind-retry. Store the signatures you’ve credited and reconcile on your server before retrying.

Expiring a stale price

Every charge expires after 90 seconds of the approval sheet being up. A request left open indefinitely is one whose price you can no longer stand behind and whose outcome your server can never settle. expiresInSeconds changes that window if 90 seconds is wrong for you:
The clock starts when the approval sheet appears, not when you call — the window is the user’s time to decide, and the sheet counts it down for them. If it runs out the sheet stays put with the slider disabled, and the charge rejects charge_expired once they dismiss it — nothing signed, nothing moved, and the idempotencyKey unconsumed, so you can re-quote and charge again with it. That’s distinct from payment_denied: the user didn’t say no, your price did.

Recovering a lost charge

charge() hands you the signature, and your page hands it to your server. If that page dies first — the app is killed, the connection drops, the user force-quits — the payment still settled. Money is at your payment address, and nothing you hold points to it. A reference closes that. Your server mints one, stores it with the order, and passes it to charge(). The payment carries it on-chain as an inert marker, and Solana indexes transactions by every address they touch — so the id exists before the payment does, and stays resolvable from the chain alone.
Then sweep orders that never reported back:
Node.js
Never call charge() again to find out what happened. A charge whose result you didn’t hear may well have settled; retrying without the order’s original idempotencyKey charges the user a second time. Ask the chain, not the user.

What the result means

findChargeByReference returns null until a charge lands, which for a while means not yet rather than not paid. The wait is bounded, though, because charges expire: once the window has passed and the transaction’s own blockhash has died with it, nothing can still arrive. Allow about five minutes from the call at the default 90-second window — the window, plus roughly a minute of blockhash life and another for your RPC’s index to catch up — and after that a null is conclusive and the order can be closed. Widening the window with expiresInSeconds widens that bound by the same amount. What comes back is the payment, not your order: the lookup reads the chain and has no idea what you charged for. Check payee, mint and amountCents against the order exactly as you do on the live path — the same discipline, not an extra one the sweep owes you. amountCents is the one that carries weight here. This lookup returns the oldest charge carrying the reference, which is what keeps a stranger from attaching one to a payment of their own: they can only do that once your charge has landed, so theirs is always the newer transaction. The payer, though, holds the reference before they pay it — you passed it to charge() in their browser — so one who lands a cheap transfer to your payee first would take that slot. Comparing the amount to the order costs nothing and closes it.
The sweep reads your own RPC endpoint, so how far back it can see is yours to own — SOLANA_RPC_URL should point at a provider that keeps address history if you reconcile on a long cycle. It throws ConfirmChargeError('rpc_error') when the chain can’t be read at all: a failure to look is not the same as an answer of no, and shouldn’t close an order.
Keep a reference random and single-use. createReference() gives you 32 random bytes. Once the charge lands, the reference is permanently public and resolves to that payer’s wallet, the amount, and the time — so never derive one from an order id, a user id, or anything else you’d mind publishing, and never reuse one across orders.
Passing a reference requires a current Bankroll app; older ones reject with update_required instead of settling a charge you could never look up. Catch it and retry without one if you’d rather degrade than block.

Confirm on your server

Before you grant anything of value, confirm on your server that the payment:
  1. has settled,
  2. was in the asset you priced the order in,
  3. was for the amount you expected,
  4. to your fixed payment address (capabilities.payments),
  5. and hasn’t already been redeemed (guard against replay by storing the signature — it’s unique per payment).
The signature charge() returns is a Solana transaction signature — the payment is an on-chain transfer of Bankroll’s USD stablecoin (HSUSD: SPL mint 4FVaHEubcqws8hKwJSiW8f8CmKGUyMsBxTKUytcGdRvd, 9 decimals, so 1 HSUSD = $1) from the user to your payment address. confirmCharge from @joinbankroll/sdk/server fetches the settled transaction from a Solana RPC endpoint and returns the payment’s facts:
Node.js
confirmCharge handles the on-chain mechanics: it polls briefly while the transaction becomes visible to your RPC node (a payment the user just watched settle can lag a node’s index by a few seconds), rejects transactions that failed on-chain, and parses the transfer exactly. It throws ConfirmChargeError with a code of not_found, failed_on_chain, not_a_payment, or rpc_error — a return value always means the transfer settled.
Which RPC endpoint. SOLANA_RPC_URL names the endpoint the server half uses. Unset, it falls back to Solana’s public endpoint and warns once per process — enough to develop against, but it rate-limits under concurrency, so anything taking real money wants its own. usingPublicRpc() from @joinbankroll/sdk/server reports whether the fallback is in play, and rpcUrl() names the endpoint in use.
All three checks release money, and the payee check is the one to never skip: a settled transfer of the order’s exact amount from the session wallet to any other wallet the user controls passes the payer and amount checks — so without it you’d credit an order you were never paid for. A resolved charge() means the transfer settled, but only these server-side checks should release value.

Reading the memo

The optional memo you pass to charge() is written on-chain with the payment and comes back as payment.memo — the exact string you passed (trimmed, capped at 80 characters), or null. The value is read from the chain, though, and a transaction built outside charge() can carry any memo — so treat it as untrusted input, and prefer the unique signature as the primary correlation key.

Expected rejections

Every failure is a BankrollError with a stable snake_case code (the host’s original message is preserved in message). Two codes are expected outcomes — the host has already handled the user experience, so don’t surface a duplicate error:

Error codes

All codes session() and charge() can throw, as BankrollError.code: The code union also reserves superseded_consent, per_charge_declined, blocked_origin, and charge_cap_exceeded — stable names you can switch on today.
Fees and guardrails. Bankroll charges no fee on charge(). It enforces anti-fraud and velocity guardrails. Today a guardrail trip arrives as unknown with the host’s reason preserved in message; once the reserved codes above (charge_cap_exceeded, blocked_origin) go live it will surface as one of those instead. Handle either like a declined charge.