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
amountCentsormemorejectsidempotency_conflict.
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:
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.
Node.js
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.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:- has settled,
- was in the asset you priced the order in,
- was for the amount you expected,
- to your fixed payment address (
capabilities.payments), - and hasn’t already been redeemed (guard against replay by storing the signature — it’s unique per payment).
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.Reading the memo
The optionalmemo 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 aBankrollError 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 codessession() 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.