charge() moves money one way: user → your app. Payouts, winnings, and refunds
move the other way with pay() — an on-chain HSUSD transfer from your own
treasury to the user’s wallet. There is no payout API and Bankroll is not in
the path: pay() builds, signs, and broadcasts entirely against your own RPC,
from a treasury only you control. There is also no separate refund API —
partial or full refunds, like winnings, are the same transfer.
The user’s wallet is session.user.wallet
The wallet on a verified session’s user is the user’s
wallet address — the payout destination. Always take it from a verified
session, never from client input.
Send with pay()
Payouts settle by default in the same stablecoin charge() does — HSUSD (SPL mint
4FVaHEubcqws8hKwJSiW8f8CmKGUyMsBxTKUytcGdRvd, 9 decimals, 1 HSUSD = $1). Fund
a treasury — this can be the same capabilities.payments wallet that charge()
settles into — with the HSUSD you take in, and pay users out of it.
Node.js
pay() builds the transfer (creating the recipient’s token account if needed,
with your treasury paying the one-time rent), signs with BANKROLL_TREASURY_KEY,
broadcasts to your RPC endpoint, and resolves only once the transfer is
confirmed on-chain. The treasury also pays the network fee, so keep a little
SOL on it.
Pass token: "<mint>" to pay out one of your own
app tokens instead. Pay back in the asset that paid: sell
something for tokens and pay out HSUSD, and you have built a way to turn credit
you give away for free into real money.
Custom signing (Privy, Turnkey, KMS)
If your treasury key lives in a wallet service instead of an environment variable, pass asigner: pay() still builds and confirms the transaction,
and the signer signs and broadcasts it.
For a Privy server wallet the SDK ships a drop-in
(npm install @privy-io/node — an optional peer, only needed for this entry).
Sponsorship is on by default, so Privy pays the fee and token-account rent, the
treasury holds no SOL, and the key never leaves Privy:
Node.js
idempotencyKey names one logical payout: Privy dedupes retries with the
same key for 24 hours, resolving with the original signature instead of
broadcasting again.
For any other wallet service, implement the two-field PaymentSigner yourself
— it is the same shape pay() uses by default, keypairSigner(secretKey),
which the server entry also exports:
Node.js
PayError and
carry no code — so treat any non-PayError failure as unknown-outcome and
lean on your wallet service’s idempotency before retrying.
The payout lifecycle
pay() is the composition of steps the SDK also exposes directly. Apps with
real payout bookkeeping use the steps, and the order is the point: build →
sign → store → send → confirm. Signing is deterministic, so the signature —
the transaction’s final on-chain id — exists before anything is broadcast.
Store it in the same write that marks the payout submitted, and there is no
crash window in which money can move under an id your database doesn’t know.
Node.js
confirmPayout
is the reconciliation primitive: re-run it against the stored signature and
lastValidBlockHeight any time (a sweep over stuck rows is the standard
pattern). It resolves to confirmed or failed_on_chain, or throws expired
(ledger-searched proof the attempt never landed and never can — the one
outcome that makes building a fresh transaction safe) or
confirmation_timeout (not knowable yet — leave the row and sweep again).
signPayout(transaction) signs bytes you built separately with buildPayout.
Both need a signer that signs locally, like the default keypair signer — a
wallet service that re-signs at send time cannot know a signature in advance
(the Privy signer throws and points at its idempotencyKey instead, which
plays the role there that the stored signature plays here).
“Confirmed” means the cluster confirmed the transaction — the same commitment
your wallet shows. Like every payment system’s success status, it is a
point-in-time fact, not an eternal guarantee; keep the signature on the row as
the permanent audit link.
Idempotency is yours
HSUSD transfers are final and irreversible — there are no chargebacks — so one settled order must never pay out twice. That guard lives in your database, not in the SDK:- Compute amount and recipient on your server, from the verified session and your own settled records — never from client input.
- Keep one payout row per settled order (a UNIQUE constraint), created
before you send and carrying the signature from
buildAndSignPayout. - Never blind-retry a send whose outcome you don’t know. For failures in
its own build/broadcast/confirm path,
pay()throws a typedPayError; the code tells you whether retrying is safe.