Skip to main content
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.
Set SOLANA_RPC_URL before you pay anyone. Unset, the SDK falls back to Solana’s public endpoint, which rate-limits under concurrency — and a 429 while broadcasting surfaces as rpc_error with an unknown outcome, the one failure you cannot safely retry. The fallback warns once per process rather than failing, so it will not stop a deploy that forgot 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 a signer: 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
The 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
Errors a signer throws surface as-is — they are not wrapped in 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
This gives your payout row the state machine every production payout system keeps: created (row exists, amount fixed) → submitted (signature stored, then broadcast) → confirmed | failed | expired. A row stuck in submitted is never guessed at and never re-sent on a hunch — 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).
When you reconcile old payouts, confirm against an endpoint with full transaction history. A heavily pruned endpoint can return nothing for a long-since-landed transaction, which reads as expired — and paying again on that signal would double-pay.
“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:
  1. Compute amount and recipient on your server, from the verified session and your own settled records — never from client input.
  2. Keep one payout row per settled order (a UNIQUE constraint), created before you send and carrying the signature from buildAndSignPayout.
  3. Never blind-retry a send whose outcome you don’t know. For failures in its own build/broadcast/confirm path, pay() throws a typed PayError; the code tells you whether retrying is safe.
One standard-Solana property to know: two payouts with identical recipient, amount, and memo built in the same instant can serialize to byte-identical transactions — which the network treats as one transaction, executing a single transfer while both calls report the same signature. Payouts that can fire together must be distinguishable, and a per-order memo (as above) does it.
The unknown-outcome cases (confirmation_timeout, and rpc_error with a signature) are what double-pays people. With the default signer the error also carries lastValidBlockHeight — persist both on your payout row, and retry only once the signature still hasn’t landed and that height has passed. With a custom signer no height is claimed (a sponsoring service may have re-signed with a fresh blockhash) — rely on the signature’s on-chain fate plus your wallet service’s idempotency instead.