# Build with an agent Source: https://docs.joinbankroll.com/build/agents Pointing a coding agent at Bankroll is the easiest way to get started. These docs are built to be read by agents as much as by people. If you build with Claude Code, Cursor, Codex, or anything like them, this is the best way to work. ## Guided setup Before you get started with your agent, please perform the following steps: **1. Install the Bankroll skill globally** It teaches your agent everything it needs to know. ```bash theme={null} npx skills add inplayinnovation/bankroll-sdk --skill bankroll -g ``` If you're using Claude Code, make sure you select it from the list, as it's not enabled by default! **2. Create your app's folder** In your Terminal, navigate to the folder where all your projects live (for example: `~/Projects`) and create a folder for your new Bankroll app. Whatever folder name you choose here will become your app's name, so if you wanted to call your app My Cool App you would do: ```bash theme={null} mkdir my-cool-app && cd my-cool-app ``` **3. Start your agent in that folder** (`claude`, `codex` or whatever your agent's command is). Starting *here* matters: your agent's project memory, its settings, and the scaffolded `AGENTS.md` all key off this directory. **4. Once your agent is running, simply say:** > Set up my Bankroll app. ### What the agent does with that The skill sends it to this page, and these are its steps. If you are the agent, here are the steps you need to follow to get the user started with building their first app on Bankroll. Please pause at each **Gate** and wait for the user to give you the green-light to continue: 1. Read the Bankroll developer docs at [`llms-full.txt`](https://docs.joinbankroll.com/llms-full.txt) 2. Scaffold the starter app into the current directory: `npm create @joinbankroll/app@latest .` — the folder's name becomes the app's name. * the starter app's AGENTS.md and the Bankroll docs are now your guide 3. Add the Bankroll MCP server, user-scoped, so it follows the user into every future project: * The docs serve an MCP server at [https://docs.joinbankroll.com/mcp](https://docs.joinbankroll.com/mcp) * It provides search over these docs, a read-only filesystem of every page, and a `submit_feedback` tool — an agent that finds a wrong or confusing page can report it to the Bankroll team directly. Add the MCP wherever your agent harness takes an HTTP MCP server; prefer user scope, so it follows the user across projects. 4. Run `npm run dev` **as a background task** (it is a long-running server), then put a scannable QR code **directly into your chat reply**. The deliverable of this step: the user points their phone camera at your reply and the starter app opens inside Bankroll (they must have the Bankroll app installed from the App Store or Play Store). Three things that do NOT deliver that, in any agent harness — do not attempt them: * **Relaying the QR `bankroll dev` prints to a TTY.** It is drawn with ANSI color codes — the contrast is in the colors, not the characters — so pasted into chat it collapses into a uniform block of `▀` glyphs. Nothing survives. * **Pointing the user at your background task's output.** It is never shown to them. * **Sending the QR as an image file, attachment, or side-panel artifact.** In a plain terminal none of those render inline. What works everywhere — monospace glyphs in a fenced code block: * When its stdout is not a TTY (a piped or backgrounded run — the agent case), `bankroll dev` 0.3.0+ already prints the QR as plain block glyphs with the full play link under it. Re-print both into your reply verbatim, the QR inside a fenced code block. * On an older CLI, or after a tunnel restart, rebuild it yourself: take the tunnel URL from the dev output (the `https://.trycloudflare.com/app` line), build the play link `https://joinbankroll.com/play?url=`, and generate the QR with `qrcode-generator` — already in the project's `node_modules` as a dependency of `@joinbankroll/cli`: ```bash theme={null} node -e "const q=require('qrcode-generator')(0,'M');q.addData(process.argv[1]);q.make();console.log(q.createASCII())" "" ``` * Always print the play link as plain text under the QR: if scanning fails, opening that link in a desktop browser renders the same QR as a web page. Phone cameras read block-glyph QRs at either polarity, so light and dark terminal themes both scan. **Gate: the user scans the QR code.** The starter app opens inside Bankroll on their phone, hot reload included. Once the user has confirmed that this works, you can continue to the next step. 5. Tell the user to join the Bankroll Discord at [https://discord.gg/FH3BbAM7t6](https://discord.gg/FH3BbAM7t6). We will give them some SOL (\~\$5) to fund their treasury wallet so they can get started testing real-money operations. This should cover initial gas costs. **Gate: wait for the user to confirm they've joined the Discord.** Once they've confirmed that, move on to the next step. 6. \*\*Gate: Stop here and ask the user what they want to build. We have the demo-app working on the user's device and they are in the Bankroll Discord, where they can easily get dev help from the Bankroll team. Now is the time to build something exciting! Ask them what they want to build today and start working from there. When they are ready to move value, start with [app tokens](/build/app-tokens) to get them started with their own currency for their app (their treasury wallet should be funded with the free SOL they got from joining the Discord server -- this should cover initial gas costs) ## What an agent can read * **[`llms-full.txt`](https://docs.joinbankroll.com/llms-full.txt)** — every page of these docs in one fetch. * **[`llms.txt`](https://docs.joinbankroll.com/llms.txt)** — the index, when an agent would rather pick pages. * **Any page as markdown** — append `.md` to its path: [`/build/payments.md`](https://docs.joinbankroll.com/build/payments.md). # App tokens Source: https://docs.joinbankroll.com/build/app-tokens Issue your own token — promo credit, or funds for testing — and charge and pay it out with the same calls you use for real money. An app token is a token **you** create and hand out for free: promo credit, a welcome bonus, or funds for exercising your money loop without spending real money. It is worth nothing outside your app, which is the point — you can give away as much as you like and it can never be cashed out. Bankroll shows it as your app's funds, and `charge()` and `pay()` settle in it with the same calls they use for HSUSD. The only difference is one field. An app token costs you nothing to create, so **never release HSUSD-priced value for one**. Check which asset actually paid, and pay back in the asset that paid. [Below](#check-which-asset-paid). ## Mint it Your token must be **9 decimals**, with one token worth one dollar inside your app. That is the only shape charges settle in — Bankroll refuses any other scale before it signs, so this is not a preference. Create the mint with your treasury as the mint authority, and mint the supply to your treasury: it needs to hold the supply to pay anyone back. ```bash theme={null} spl-token create-token --decimals 9 # prints spl-token create-account --owner spl-token mint 1000000 ``` Creating a mint costs about **0.0035 SOL** in one-time, refundable account rent — supply is a number, so a million tokens cost the same as one. Skip the freeze authority: a token nobody can freeze is easier to reason about, and freezing your own users' balances is a footgun. The [starter](https://github.com/inplayinnovation/bankroll-starter) ships the CLI for this: `npx bankroll token create --name "Acme Credits"` does all of the above with the treasury key it already has, and writes the mint into `app-tokens.json` — the `appTokens` claim your manifest serves, which is what names the token inside Bankroll. The Metaplex metadata below is still yours to add: it is what names the token everywhere else. ## Name it A mint holds no name, symbol, or logo. Those live in a separate **Metaplex metadata account**, and `spl-token create-token` does not create one — you add it with a `CreateMetadataAccountV3` instruction signed by your mint authority, pointing at JSON you host: ```json theme={null} { "name": "Acme Credit", "symbol": "ACME", "description": "Promo credit for Acme.", "image": "https://acme.example/token.png" } ``` Bankroll does not read any of it — the wallet names your token from the `appTokens` entry in your manifest, so it displays correctly with or without this. Everywhere else does read it: with no metadata account your token is **"Unknown Token"** in explorers, in other wallets, and in your users' own transaction history. Host the JSON somewhere you control long term; the URI is capped at 200 characters and changing it later costs an on-chain update, while editing the JSON behind a stable URI costs nothing. Do not reach for Token-2022's `NonTransferable` extension to stop people moving your token around. A charge settles as a **transfer to your payment address**, so a non-transferable token cannot be spent in your app at all — and you would lose refunds, support credits, and every treasury operation with it. Bankroll already refuses to buy, sell, or send app tokens in the wallet. ## Declare it Add the mint to your [manifest](/build/manifest) under `appTokens`. This is what lets a charge settle in it, and what makes Bankroll show it as your app's funds rather than an unattributed holding. ```json theme={null} { "manifestVersion": 1, "sub": "https://acme.example", "aud": "bankroll-app-host", "capabilities": { "session": true, "payments": "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin" }, "appTokens": { "Fh2EUwnL52CbeHttBGdW8yHKshvCbVR7pTEa5JYLKxJm": { "name": "Acme Credits", "description": "Promo funds for Acme Games" } } } ``` The keys are the mints. Declaring them is also a **limit**: Bankroll lets your app charge HSUSD or the mints you declared, and nothing else — so a page that gets hijacked can reach your own token, never a user's unrelated holdings. ## Charge in it Name the mint on `charge()`. Omit `token` and the charge settles in HSUSD, as before. ```ts theme={null} const signature = await bankroll.charge({ amountCents: 500, token: "Fh2EUwnL52CbeHttBGdW8yHKshvCbVR7pTEa5JYLKxJm", }); ``` The user sees which asset they are spending on the payment sheet, so a charge in your token is never mistaken for one in real money. ## Check which asset paid `confirmCharge` reports the `mint` that paid. **Checking it is not optional.** The signature is supplied by the client, so anything settled on-chain reaches your server — including a worthless token the sender minted themselves, which passes every other check. ```ts Node.js theme={null} import { confirmCharge, HSUSD_MINT } from "@joinbankroll/sdk/server"; const APP_TOKEN = "Fh2EUwnL52CbeHttBGdW8yHKshvCbVR7pTEa5JYLKxJm"; const payment = await confirmCharge(signature); if (payment.payee !== PAYMENT_ADDRESS) throw new Error("not paid to your address"); if (payment.mint !== HSUSD_MINT && payment.mint !== APP_TOKEN) { throw new Error("paid in an asset we don't accept"); } if (payment.amountCents !== order.amountCents) throw new Error("wrong amount"); if (payment.payer !== session.user.wallet) throw new Error("paid by another wallet"); // Record which asset paid, so what you give back can match it. ``` ## Pay it out `pay()` takes the same field. Pay back in **the asset that paid** — if you sell something for tokens and pay out HSUSD, you have built a way to turn free credit into real money. ```ts Node.js theme={null} await pay({ to: session.user.wallet, amountCents: order.amountCents, token: order.mint, // whatever bought it }); ``` Your treasury needs a balance of the token to pay out, the same way it needs HSUSD — mint yourself more whenever you like; you hold the mint authority. ## What users see A declared token appears in the user's Bankroll wallet under **App Tokens**, with your app's name and icon and the name you declared, valued at a dollar a token. It is grouped apart from tradeable holdings because that is what it is: money that spends in your app and nowhere else. Users only see it while your app is connected. Disconnect, and it becomes an ordinary token in their wallet. # Balances and deposits Source: https://docs.joinbankroll.com/build/balances Read your app's view of the user's value with balances(), and open the host's deposit flow with deposit(). `charge()` already handles a user who is short — Bankroll prompts them to add funds and your call rejects `insufficient_funds`. These two calls are for the cases where your app wants to act *before* that: show what the user has, and offer to top up on your own terms. **Prerelease.** `balances()` and `deposit()` are still settling and their contract may change. Both need a current Bankroll host and are feature-detected, so an older one rejects with `update_required` rather than throwing — treat that as "this user can't do it yet" and fall back to the flow you'd have shipped without them. ## `balances()` ```ts theme={null} import { bankroll } from "@joinbankroll/sdk"; const { cashCents, creditsCents, tokens } = await bankroll.balances(); ``` Your app's view of the user's value — and only yours. Undeclared mints and other apps' credits are unreachable by construction, so this can't be used to survey what a user holds elsewhere. | Field | Type | Meaning | | -------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `cashCents` | number | The user's cash — HSUSD — in whole US cents. | | `creditsCents` | number | Credits registered to **your app**, in whole US cents. `0` if your app has none. | | `tokens` | `Record` | One entry per mint your manifest declares in [`appTokens`](/build/app-tokens), keyed by mint — the same shape as the manifest. | ```ts theme={null} { cashCents: 2450, creditsCents: 500, tokens: { "7Nk3…": { amount: 12, name: "Acme Chips" }, }, } ``` **`amount` is whole tokens, not cents.** App tokens are 9-decimal and one token to the dollar, so `amount: 12` is twelve Acme Chips. The two cents fields are cents. Cents are truncated down — never show a user money they don't have. A mint you declare that the user has never held reads `0` rather than going missing, so you can render every token in your manifest without a presence check. `name` comes from your own manifest entry, which is what keeps a token's display name consistent whether Bankroll or your page draws it. The host answers from the balance store it already keeps live — the same numbers its wallet renders. The call costs no upstream fetch, so polling it as a display heartbeat is fine. This is a **display** value, read from the client. Never price an order, settle a bet, or release value against it. What a user can pay is decided by whether their `charge()` settles, and what they're owed is decided by your own records — see [Confirm on your server](/build/payments#confirm-on-your-server). ## `deposit()` ```ts theme={null} await bankroll.deposit(); // the host's default source await bankroll.deposit({ method: "card" }); // preselect the card sheet ``` Presents the host's deposit UI. It **resolves once the modal is presented** — not when money arrives. The deposit itself runs in native UI and settles long after this call returns, so treat a resolved `deposit()` as "the user is now looking at the deposit sheet" and nothing more. To notice funds landing, read `balances()` again when the user comes back. `method` preselects a funding source. It is a hint: the host validates it and falls back to its default when the value is unknown or the user isn't eligible, so an unrecognized string opens the sheet rather than failing. | `method` | Source | Availability | | --------- | ------------------------------------------------------------------------------------- | ------------ | | `card` | Debit or credit card — [settles as your app's credits](#app-credits) once provisioned | Live | | `cashapp` | Cash App → the user's cash (HSUSD) | Live | | `solana` | An on-chain transfer into the user's wallet | Live | | `bank` | Bank transfer | Coming soon | | `interac` | Interac | Coming soon | | `moonpay` | MoonPay | Coming soon | A coming-soon value is accepted today — the sheet opens on the host's default source until that method ships, at which point the same preselection starts working with no change on your side. ### App credits For an app with credits, **every card purchase opened from your app settles as your app's credits**: value scoped to your app, shown in the user's wallet as yours, and readable back as `creditsCents` in [`balances()`](#balances). There is nothing to select — `card` does it. The other sources (Cash App, an on-chain transfer, and the coming-soon set) top up the user's own cash. This requires Bankroll to have **provisioned credits for your app** — a per-app setup on Bankroll's side, keyed to your origin; contact the Bankroll team. Without it, the card sheet still opens but the purchase does not settle to your app. The resolution is always from your app's verified origin, never from anything your page passes — so a page cannot route a purchase into another app's account. ## Together The pattern these are for: decide with your own numbers, offer the top-up, and still let `charge()` be the thing that moves money. ```ts theme={null} import { bankroll, BankrollError } from "@joinbankroll/sdk"; async function buy(order) { const { cashCents, creditsCents } = await bankroll.balances(); if (cashCents + creditsCents < order.amountCents) { await bankroll.deposit({ method: "card" }); return; // they're in the deposit sheet — pick this back up when they return } try { const signature = await bankroll.charge({ amountCents: order.amountCents }); await confirmOnServer(signature); } catch (e) { // The balance was a hint; the charge is the authority. if (e instanceof BankrollError && e.code === "insufficient_funds") return; throw e; } } ``` Handle `update_required` from either call by skipping the pre-check entirely: `charge()` alone works on every host, and its `insufficient_funds` rejection already sends the user to add funds. # Changelog Source: https://docs.joinbankroll.com/build/changelog Release history for @joinbankroll/sdk. The reference pages describe the latest version. The reference pages track the **latest** `@joinbankroll/sdk`. This page is the per-version history — check it to see when a feature landed or what changed between the version you have and the latest. Install the latest with: ```bash theme={null} npm install @joinbankroll/sdk@latest ``` Versions follow [semantic versioning](https://semver.org). While the SDK is pre-1.0 a minor release can carry a breaking change; those are called out below. **`haptics()` — the physical half of your UI.** [`bankroll.haptics({ type })`](/build/haptics) plays a typed vibration through the phone's engine: impacts (`light` / `medium` / `heavy`), outcomes (`success` / `warning` / `error`), or `selection`. An omitted or unknown type plays the host default, a heavy impact. Decoration only, so unlike every other capability it **never rejects** — in a plain browser, under an old host, or on any bridge failure it resolves having done nothing. Needs a Bankroll host at client version 4+, feature-detected; no new manifest claim, and no re-consent for already-connected apps. **Offers on your tile.** Serve [`/.well-known/bankroll-status`](/build/status) and Bankroll rings your tile with your headline when you have something on offer for the person asking — per user when they've connected you, your generic new-user offer when they haven't. Opening from the offer hands its `key` back to your app as `?offer=`. Decorative only; nothing to install. **Bankroll Verified.** The green ✓ after an app's name means Bankroll reviewed what the origin serves and signed its manifest. [What it means, what it unlocks, how to get it](/build/verified) — and it is now what makes play in your app count toward a friend's referral. **Referral links, in numbers.** [Share links](/build/share-links) now state the program your `playLink({ referrer })` feeds: new user attributed at signup, $30 of play across verified apps within 30 days, $10 each to both sides — and \$10 to a verified app's treasury per qualifying referral from its links. **Know a payout's signature before you broadcast it.** [`buildAndSignPayout()`](/build/payouts#the-payout-lifecycle) builds and signs in one call, returning the transaction, its deterministic signature, and its expiry with nothing sent — so your payout row stores the signature in the same write that locks it, and recovery is always a question the chain can answer. `signPayout()` does the signing half for bytes you built separately, and a custom `PaymentSigner` can opt in with `signTransaction`. Purely additive — no existing call changes behavior. The payout lifecycle docs now teach the full order: build → sign → store → send → confirm. **`iconDigest` on `manifestRoute`.** [`manifestRoute()`](/build/next#the-manifest-route) takes an optional `iconDigest` — a Subresource Integrity hash of your icon's bytes. A changed digest is a manifest change, which is what carries a [replaced icon](/build/manifest#updating-your-icon--icondigest) to users who already connected. Omitted, like `supportUrl`, whenever it resolves to nothing. **Find a charge your page never reported.** [`createReference()`](/build/payments#recovering-a-lost-charge) mints an id your server stores with the order and passes to `charge()`. The payment carries it on-chain, so when the page dies between the charge settling and the request that would have reported it, [`findChargeByReference()`](/build/payments#recovering-a-lost-charge) still finds the charge — by an id that existed before it did. `ConfirmedCharge` now carries the payment's `signature`, so a recovered charge settles through the same code as a reported one. **[`expiresInSeconds`](/build/payments#expiring-a-stale-price) bounds a stale price.** A charge left sitting on the pay sheet rejects `charge_expired` rather than settling against a price that has moved — nothing signed, nothing moved. It defaults to 90 seconds, and the host counts it down on the sheet. Passing a reference needs a current Bankroll app — older ones reject with `update_required`. **`sendPush()` — notify your users through Bankroll.** From your server, sign a short-lived request with your push key and Bankroll delivers a notification to one of your users, titled with your app's name; tapping it opens your app at the path you choose. `pushAddress()` derives the public key your manifest declares — [`manifestRoute`](/build/next)'s new `push` entry. Push is permissioned: it works once Bankroll has signed your manifest, which attests the key, and only toward users who have opened your app. The push key is its own secret (`BANKROLL_PUSH_KEY`) — pushing and paying are different powers, so it is deliberately not the treasury key. **Share links carry a referrer.** [`playLink(url, { referrer })`](/build/share-links) appends the wallet of the Bankroll user sharing the link, so an app can piggyback on Bankroll's referral program instead of building its own — pass `session.user.wallet` and a new Bankroll user who opens the link is attributed to them. The referrer must be a real Bankroll user's wallet; the program is person to person, so an app's own treasury is attributed to nobody. An unrecognized referrer costs the attribution, never the link. **`balances()` and `deposit()` — prerelease.** [`bankroll.balances()`](/build/balances) returns your app's view of the user's value — their cash, your app's credits, and the balances of every mint your manifest declares. [`bankroll.deposit()`](/build/balances#deposit) presents the host's deposit UI, optionally preselecting a funding source. Both are feature-detected and need a current Bankroll host; an older one rejects with `update_required`. Prerelease — the contract may still change. **`supportUrl` on `manifestRoute`.** [`manifestRoute()`](/build/next#the-manifest-route) takes an optional `supportUrl` and omits the claim when it resolves to nothing — an empty claim is still a claim, and would re-ask every connected user for consent once it gained a value. **Breaking: `publicOrigin()` is gone.** It read `BANKROLL_DEV_TUNNEL_ORIGIN` so a development landing page could render a link a phone could reach; in production it was [`getOrigin()`](/build/next#the-origin) with extra steps. Call `getOrigin()`. Removing it is what lets the development CLI start your server first and read `launch` from your live manifest, so the QR code it prints opens your app rather than your landing page. **Breaking: `manifestRoute`'s `appTokens` is a map.** It took a single mint; it now takes `Record`, matching the [manifest claim](/build/manifest#claims) and letting an app issue several tokens that each carry their own display strings. ```ts theme={null} // before appTokens: () => CHIPS_MINT, // after appTokens: () => ({ [CHIPS_MINT]: { name: "Acme Chips" } }), ``` **Three new entry points**, all optional — the pieces every Built-for-Bankroll app was writing itself. * [`@joinbankroll/sdk/next`](/build/next) — `getOrigin()`, `getSession()` / `requireSession()`, `requireIdentity()`, and `manifestRoute()`, which serves `/.well-known/bankroll.jwt` so you never hand-assemble the format. Plus the treasury: `treasuryAddress()` derives your payment address from `BANKROLL_TREASURY_KEY`, so what your manifest advertises can't drift from the wallet that signs. * [`@joinbankroll/sdk/store`](/build/store) — durable JSON with an atomic create and compare-and-swap, so a payment signature can't be redeemed twice without provisioning a database. Filesystem and Vercel Blob backends behind one interface; `@vercel/blob` (`>= 2.3.0`) is an optional peer. * [`@joinbankroll/sdk/react`](/build/react) — `useBankrollStatus()` / `useBankrollChecked()` that agree across hydration, `bankrollFetch`, `verifyIdentity()`, and a development overlay. `react` (`>= 18`) is an optional peer. **`SOLANA_RPC_URL` is now optional.** Unset, the server half falls back to Solana's public endpoint and warns once per process instead of failing, so an app can take its first payment before configuring an RPC. `usingPublicRpc()` reports whether the fallback is in play. Set your own endpoint before you [pay anyone](/build/payouts). The public one rate-limits under concurrency, and a 429 while broadcasting a payout surfaces as `PayError('rpc_error')` with an unknown outcome — the one failure you cannot safely retry. **App tokens.** [`charge()`](/build/payments) and [`pay()`](/build/payouts) take an optional `token` — a mint you issue yourself, declared in your manifest's [`appTokens`](/build/app-tokens) — so you can sell and pay out in your own promo credit with the same calls you use for real money. Declaring your mints is also a limit: your app can charge HSUSD or those mints, and nothing else. **Breaking: `confirmCharge()` no longer guarantees HSUSD.** It now reports the `mint` that paid and leaves the judgement to you, alongside the payee, amount and payer checks you already make. A server that upgrades **without adding a mint check will accept a token the sender minted for nothing** — the signature comes from the client, so any settled transfer reaches `confirmCharge`, and a worthless token passes every other check. Add: ```ts theme={null} import { confirmCharge, HSUSD_MINT } from "@joinbankroll/sdk/server"; if (payment.mint !== HSUSD_MINT) throw new Error("paid in another asset"); ``` Every asset a charge settles in carries HSUSD's scale — 9 decimals, one token to the dollar — so amounts stay in whole US cents throughout. Bankroll refuses a mint of any other shape before it signs. **`iconDigest` manifest claim.** Added the OPTIONAL [`iconDigest`](/build/manifest) claim — a Subresource Integrity hash (`sha256-`) of your icon's bytes. Declare it so a replaced icon reaches users who have already connected your app, instead of staying cached. **`ConfirmedCharge.slot`.** [`confirmCharge()`](/build/payments) now returns the `slot` the transfer landed in — a chain-assigned, monotonic number. Use it as a stable ordering key for a purchase listing without giving up signature-keyed idempotency. **More reliable payout reconciliation.** [`confirmPayout()`](/build/payouts) now searches the transaction ledger instead of only the recent status cache, so confirming a long-since-landed payout resolves correctly. When you reconcile **old** payouts, confirm against an endpoint with full transaction history — a pruned endpoint can report a landed transaction as `expired`, and paying again on that signal would double-pay. **Dependency hardening.** Inlined the token-transfer instructions the SDK builds, removing a transitive dependency that carried an unfixable security advisory. No API changes. **Payout lifecycle.** The single-call payout became a three-step lifecycle — [`buildPayout` → `sendPayout` → `confirmPayout`](/build/payouts) — with a caller-owned payout-row state machine (created → submitted → confirmed | failed | expired) and `confirmPayout()` as the reconciliation primitive. `pay()` remains as their composition. **Server-side charge confirmation, and paying a user.** * Added [`confirmCharge(signature)`](/build/payments) — read a settled charge's facts from chain (`{ payer, payee, amountCents, memo }`) and verify them before releasing value. * Added a server payout: `pay({ to, amountCents, memo? }, { signer })`, signed with a `PaymentSigner` — `keypairSigner(secretKey)` or `privySigner` from `@joinbankroll/sdk/privy`. **Breaking — the client charge method was renamed** `bankroll.pay()` → [`bankroll.charge()`](/build/payments). The freed `pay()` name is now the server payout function. Update your charge calls when you upgrade. (The host bridge's wire method stays `pay` — it's a versioned protocol, so nothing on the host side changes.) **Charge error codes.** Added `idempotency_conflict` (an `idempotencyKey` reused with different parameters) and `payment_denied` (the host declined at the pay sheet), and documented `idempotencyKey` on the charge input. **`session()`.** The identity capability is now `session()`; `identity()` remains as a deprecated alias for older hosts. Added the `BankrollSession` type on the server entry. **Initial release.** The Build-on-Bankroll client and server-side session-token verification (`verifyToken`). # Deploy Source: https://docs.joinbankroll.com/build/deploy Ship to your own host — the environment, the Blob store, a production treasury, and the link users open. A Built-for-Bankroll app deploys like any web app, to your own host — Bankroll never hosts it. Any origin that serves HTTPS works; this page is the Vercel path the starter is wired for. ## The environment | Variable | What it is | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `BANKROLL_TREASURY_KEY` | The app's one secret: a base58 Solana secret key that receives payments and signs payouts. Without it the app still runs — it just can't take or send money, and its manifest says so. | | `BANKROLL_APP_NAME` | Shown when a user connects the app. | | `SOLANA_RPC_URL` | Your RPC endpoint. Optional to start, **required before you pay anyone** — see below. | | `BLOB_READ_WRITE_TOKEN` | Set automatically when a Blob store is connected — nothing to copy by hand. | | `STORE` | Leave unset in production: the [store](/build/store) then uses Vercel Blob. `STORE=fs` is the local-files setting `.env.local` carries in development. | `.env.local` is gitignored, so the dev configuration — and the dev treasury — never reach a deployment. ## Vercel ```bash theme={null} npx vercel link # create or connect the project npx vercel blob create-store acme-store # injects BLOB_READ_WRITE_TOKEN npx vercel env add BANKROLL_APP_NAME production npx vercel deploy --prod ``` Connecting the Blob store injects its token into deployments automatically. Do **not** `vercel env pull` it into `.env.local` — that overwrites your dev setup, and worse, points local code at production data. ## A production treasury Generate a **fresh** key for production — never the dev key, which sits in plaintext on your machine — and set it so the secret is never printed or written to disk: generate it and pipe it straight into a sensitive variable. The public address goes to stderr, so you still see which wallet to fund. ```bash theme={null} node -e "const{generateKeyPairSync}=require('crypto'),bs58=require('bs58').default;const{publicKey,privateKey}=generateKeyPairSync('ed25519');const a=publicKey.export({format:'der',type:'spki'}).subarray(-32),s=privateKey.export({format:'der',type:'pkcs8'}).subarray(-32);console.error('treasury:',bs58.encode(a));process.stdout.write(bs58.encode(Buffer.concat([s,a])))" \ | npx vercel env add BANKROLL_TREASURY_KEY production --sensitive ``` Fund the address with the HSUSD you pay out of, and keep a little SOL on it — the treasury pays the network fee, and the one-time rent when a payout creates a recipient's token account. **Never replace a funded treasury.** Swapping the variable strands the balance — it does not move it. Rotating means: create the new key, move the old wallet's entire balance to the new address (`npx bankroll treasury send`), and only then swap the variable. A sensitive variable's value is shown once, at creation, never again. ## Set your RPC Set `SOLANA_RPC_URL` before the app pays anyone. Unset, the SDK falls back to Solana's public endpoint, which rate-limits under concurrency — and a 429 while broadcasting a payout 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. Any provider works. ## Open it Users open your app at: ``` https://joinbankroll.com/play?url=/app ``` — which is [`playLink()`](/build/share-links) if you'd rather not build it by hand. Before sharing it, confirm the manifest is live: ```bash theme={null} curl https://acme.example/.well-known/bankroll.jwt # → header.payload. ``` No registration or approval is required: once the manifest serves, the `/play` link opens your app for any Bankroll user. # Local development Source: https://docs.joinbankroll.com/build/dev bankroll dev — the tunnel, the QR code, the dev signing key, and the rest of the CLI. Your app runs inside Bankroll on a phone, and the host opens only public HTTPS origins — so the development loop is not `localhost:3000` in a browser tab. It is one command: ```bash theme={null} npm run dev ``` `npm run dev` is `bankroll dev`, from [`@joinbankroll/cli`](https://www.npmjs.com/package/@joinbankroll/cli) — a devDependency the [starter](https://github.com/inplayinnovation/bankroll-starter) already carries. It starts your dev server on a free port, raises a public tunnel in front of it, reads your live manifest, and prints a QR code that opens the app inside Bankroll at its `launch` path. Scan it and you are developing against the real host — session, payments, and hot reload included. ## The tunnel The tunnel is a Cloudflare quick tunnel, and it gets a **new URL on every restart** — the host cannot reopen a previous one. "Can't open this app" almost always means a dead tunnel: restart `npm run dev` and scan the new QR. The dev server's port doesn't matter — the tunnel hides it — so `bankroll dev` picks any free one. Pass `-p, --port ` to pin it. ## The QR, when an agent runs this On a TTY, the QR is drawn with ANSI colors. When stdout is **not** a TTY — a piped or backgrounded run, which is how a coding agent runs it — `bankroll dev` (0.3.0+) prints plain block glyphs instead, with the full play link under it: both safe to re-print verbatim into a chat. Setting `NO_COLOR` forces the same rendering on a TTY. If you are an agent: your task output is not shown to the user, and the colored QR cannot be relayed as text (the contrast is in the color codes, not the glyphs). Put the glyph QR in your chat reply inside a fenced code block, play link under it — the exact recipe, including the fallback for older CLIs, is in [Build with an agent](/build/agents). ## The dev signing key The first `bankroll dev` creates a signing key at **`~/.config/bankroll/keypair.json`** and injects it into the dev server's environment as the treasury. It is never written into your project, so it cannot be committed. Pass `-k, --keypair ` to use a different one. That key is your app's wallet while you develop: it receives every `charge()` and signs every payout, and it moves **real mainnet HSUSD**. Fund the dev key with only what you are willing to risk, and give a deployment its own key — see [Deploy](/build/deploy). To exercise the whole money loop without spending real money, mint an [app token](/build/app-tokens) and charge in that instead. ## Plain localhost `npx next dev` still works, for layout and UI work in a browser. It is the exception rather than the loop: a browser has no host bridge, so [`status()`](/build/quickstart#step-1--install-the-sdk-and-detect-the-host) reads `'unavailable'`, `session()` and `charge()` are off the table, and your token-guarded routes answer 401. That is the app working as designed, not a bug. ## The rest of the CLI `npx bankroll --help` is the authority; the commands today: | Command | What it does | | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `bankroll dev` | The loop above — dev server, tunnel, QR. | | `bankroll treasury show` | The signing key's address, and everything it holds. | | `bankroll treasury send ` | Send HSUSD — or one of your tokens, with `--token ` — from the treasury. | | `bankroll token create` | Create a token: 9 decimals, whole supply on your signing key, written into `app-tokens.json`. `--name` is what Bankroll calls it. See [App tokens](/build/app-tokens). | | `bankroll token list` | List the tokens this app declares. | | `bankroll token mint ` | Issue more supply of a token you control. | Commands that touch the chain take `-k, --keypair ` (defaulting to the dev key) and `--rpc ` (defaulting to `SOLANA_RPC_URL`, else the public endpoint). ## Where local state lives Scaffolding writes `.env.local` — `STORE=fs`, your app's name, and an RPC — and it is gitignored, so none of it reaches a deployment. With `STORE=fs` the [store](/build/store) keeps its documents in plain files under `bankroll/development/` in your project: open them and read what your app recorded. Tests write to `bankroll/test/` instead, so a test run can never touch the store you develop against. # An existing app Source: https://docs.joinbankroll.com/build/existing-app Add Bankroll to a Next.js app you already have — the SDK, the manifest, sessions, money, and the recovery files worth copying from the starter. The [starter](https://github.com/inplayinnovation/bankroll-starter) is a reference, not a requirement. An app you already run becomes a Built-for-Bankroll app by serving a manifest and verifying sessions — this page is that path for Next.js. On another stack, the [Quickstart](/build/quickstart) shows the same steps framework-free. ## 1. Install ```bash theme={null} npm install @joinbankroll/sdk npm install -D @joinbankroll/cli ``` The CLI is for the [dev loop](/build/dev) and your [app tokens](/build/app-tokens); the SDK is everything else. ## 2. Serve the manifest One route file, and the SDK owns the format: ```ts app/.well-known/bankroll.jwt/route.ts theme={null} import { manifestRoute } from "@joinbankroll/sdk/next"; import { treasuryAddress } from "@joinbankroll/sdk/server"; export const dynamic = "force-dynamic"; // built from the request's own host export const GET = manifestRoute({ name: () => "Acme Games", launch: "/app", // where the host boots the app; "/" serves your site payments: treasuryAddress, // null until a treasury key is set — declared once it exists }); ``` See [The manifest](/build/manifest) for every claim, and serve a square PNG at `/.well-known/bankroll-icon.png` when you have one. ## 3. Verify sessions Every route that matters reads the user from the verified token, never from the request body: ```ts theme={null} import { requireSession, requireIdentity, Unauthorized } from "@joinbankroll/sdk/next"; export const dynamic = "force-dynamic"; export async function POST(request: Request) { const session = await requireSession(request); // throws Unauthorized requireIdentity(session); // real money moves only for a verified person // session.user.wallet — the user's stable id, and payout target } ``` On the client, [`bankrollFetch`](/build/react) (or [`withBankrollToken(fetch)`](/build/session#send-it-to-your-server)) attaches the token to every request. ## 4. Move money The client calls [`charge()`](/build/payments); your server confirms with `confirmCharge()` and checks **payee, mint, amount, and payer** before releasing value, then records the signature so it can never be redeemed twice. Payouts are [`pay()`](/build/payouts) from your treasury. If you already run a database, a `UNIQUE` column on the signature is the replay guard; if you don't, [the store](/build/store) exists so you don't have to provision one. ## 5. Copy the recovery machinery A charge whose page dies before reporting back still settled — money at your address that nothing points to. The pattern that recovers it (an intent written before the charge, a `reference` carried on-chain, a sweep that finds what was never reported) is described in [Payments](/build/payments#recovering-a-lost-charge), and the starter ships a working implementation. It is app code rather than SDK code today — copy it (MIT) rather than rewrite it: | Copy | What it is | | ----------------------- | --------------------------------------------------------------------------------------------- | | `src/lib/charges.ts` | The settle checks both paths share — live and swept payments get identical scrutiny. | | `src/lib/store.ts` | One document per charge and per intent, with the atomic create that is also the replay guard. | | `src/lib/sweep.ts` | Finds charges that settled without being reported, by their reference. | | `src/app/api/charges/…` | The three routes: start an intent, confirm and list charges, pay one back out. | Adapt `charges.ts` to your own price and product; the shape is the part worth keeping. ## 6. Wire the dev loop `bankroll dev` tunnels your dev server so a phone can reach it (see [Local development](/build/dev)). Next.js blocks cross-origin hot-reload by default, so allow the tunnel in `next.config.ts`: ```ts theme={null} const nextConfig: NextConfig = { allowedDevOrigins: ["*.trycloudflare.com"], }; ``` Then `npx bankroll dev` — or make it your `dev` script, as the starter does. # Haptics Source: https://docs.joinbankroll.com/build/haptics Play a vibration through the phone with haptics() — the physical half of a moment your UI already shows. Your app runs on a phone in someone's hand, and the hand is a display too. `haptics()` plays one vibration through the phone's engine — the wheel stops, the pick lands, the win pays, and the moment registers physically as well as on screen. ```ts theme={null} import { bankroll } from "@joinbankroll/sdk"; await bankroll.haptics(); // the host default — a heavy impact await bankroll.haptics({ type: "success" }); // a completed action ``` ## The types | `type` | What it is for | | ------------------------------- | -------------------------------------------------------- | | `light` / `medium` / `heavy` | Impacts, in rising weight — ticks, landings, collisions. | | `success` / `warning` / `error` | Outcome notifications — an action completed, or didn't. | | `selection` | The finest tick — a value changing under the finger. | An omitted or unknown `type` plays the host default, a heavy impact — a value from a newer SDK degrades on an older host rather than erroring. ## It never rejects Haptics is decoration, so unlike every other capability this call **never rejects**: in a plain browser, under a Bankroll host too old to carry it, or on any bridge failure, it resolves having done nothing. Call it freely at the moments that deserve weight, and gate nothing on it. It is also fire-and-forget on the host side — the call resolves as soon as the host accepts it, not when the vibration ends, so it never holds your frame or your game loop. ## Nothing to declare Haptics moves no money and reads no data, so it rides the connection your app already has: no manifest claim, no consent line, and no re-consent for users who connected before it existed. Any connected app may call it. Requires a Bankroll host at client version 4 or later. The SDK feature-detects the method, so there is no version number for you to check. # The manifest Source: https://docs.joinbankroll.com/build/manifest The file at /.well-known/bankroll.jwt that names your app, declares its capabilities, and fixes where payments settle. Every app declares itself with a **manifest** served at **`/.well-known/bankroll.jwt`** on its own origin. Bankroll fetches it over HTTPS to learn three things: what your app is called, which capabilities it uses, and the address that receives its payments. Because Bankroll reads it from your origin, the manifest is bound to your app by where it's served — not by a signature. It's an **Unsecured JWT** — a JWT with `alg: none` and an empty signature — so there is no key to manage. [Verified](/build/verified) apps serve a Bankroll-signed version of the same manifest instead. ## Claims Decoded, the manifest payload is a small JSON object: ```json theme={null} { "manifestVersion": 1, "sub": "https://acme.example", "aud": "bankroll-app-host", "capabilities": { "session": true, "payments": "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin" }, "name": "Acme Games", "launch": "/app", "supportUrl": "https://help.acme.example" } ``` | Claim | Required | Description | | ----------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `manifestVersion` | Yes | The manifest format version. Currently the integer `1`. | | `sub` | Yes | Your app's exact canonical origin (`scheme://host[:port]`, default port omitted; no path, no trailing slash). Must equal the origin Bankroll loaded — this is what binds the manifest to your app. | | `aud` | Yes | Always the string `bankroll-app-host`. | | `capabilities` | Yes | Declares what your app uses. `session: true` lets it open a user session ([`session()`](/build/session)); `payments: "
"` enables payments **and fixes the address** every `charge()` settles to. At least one capability must be enabled. | | `name` | No | Your app's display name, shown to the user. Defaults to your origin if omitted. | | `launch` | No | Where Bankroll opens your app from its tile: a path starting with `/` (not `//`), resolved against your origin. Omit to open your origin's root. Use it to keep a marketing page at `/` and the app itself at, say, `/app`. Deep links a user follows are opened as given. | | `supportUrl` | No | Where your users get help. Bankroll shows it in your app's menu as "Help with \", and opening it hands the URL to the operating system — so a help page, a `mailto:`, a `tel:`, or a chat invite all work, and whichever app claims that link opens it. It may point anywhere; support desks usually live on another domain. Omit it and no menu item appears. | | `appTokens` | No | Tokens your app issues itself, keyed by mint: `{ "": { "name": "…", "description": "…" } }`. Declaring one lets `charge()` settle in it and shows it in the user's wallet as your app's funds. It is also a limit — your app can charge HSUSD or these mints, and nothing else. See [App tokens](/build/app-tokens). | ## Your icon Your icon is **not** a manifest claim. Serve a square PNG (256×256 or larger) at the fixed path **`/.well-known/bankroll-icon.png`** on your origin — a sibling of the manifest. Bankroll fetches it from there and shows it on your app's tile and in Connected Sites; until you serve one, it shows a monogram of your app's name. A manifest that still includes an `iconUrl` claim remains valid — the claim is simply ignored. ### Updating your icon — `iconDigest` If you replace the PNG at the same path, users who have already connected your app can keep seeing the old one. To make an update reach them, add the OPTIONAL **`iconDigest`** claim: a hash of the icon bytes as a [Subresource Integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) hash-expression (`sha256-`). Bankroll treats a changed `iconDigest` as a manifest change, so your new icon propagates to connected users. Compute it from the exact bytes you serve, and omit the claim if you don't serve an icon: ```ts theme={null} import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; const pngBytes = readFileSync("public/.well-known/bankroll-icon.png"); const iconDigest = "sha256-" + createHash("sha256").update(pngBytes).digest("base64"); // → "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=" ``` On Next.js, pass it to [`manifestRoute`](/build/next#the-manifest-route)'s optional `iconDigest` and the SDK handles the omission rules. The recipient is read from the fetched manifest, never supplied by your page. For a session-only app, omit `payments` and set `session: true` alone. ## Rules * **Served on your origin.** The manifest must live at the exact path `/.well-known/bankroll.jwt` on the same origin your app loads from. Bankroll matches by origin (scheme + host + port), so `https://acme.example` and `https://app.acme.example` are different apps with different manifests — and each manifest's `sub` must equal its own origin. * **`application/jwt`.** Serve it with `Content-Type: application/jwt`, no larger than 8 KiB, over HTTPS. Your app URL must be HTTPS too. * **`alg: none`.** The header is exactly `{ "alg": "none", "typ": "bankroll-app-manifest+jwt" }`, and the signature segment is empty — the token is `header.payload.`, with a trailing dot and nothing after it. * **Must be valid.** If the manifest is missing, unreachable, malformed, the wrong media type, or its `sub` / `aud` / `manifestVersion` are wrong, `session()` and `charge()` reject with `manifest_error`. * **Declare what you use.** Any capability you call must be enabled here. Calling one you didn't declare rejects with `capability_not_registered`. See [error codes](/build/payments#error-codes). * **Changing it re-asks your users.** A user's grant is bound to the exact manifest they approved, so editing *any* claim — including `name` and `supportUrl` — means everyone who already connected your app is asked to approve it again on their next visit. Treat these as settings you pick once. Where a value may need to move later, point it somewhere you control and redirect: `https://acme.example/support` can change destination freely, but editing the claim itself cannot. ## Serving it On Next.js, `manifestRoute()` from [`@joinbankroll/sdk/next`](/build/next) is the whole route. You declare what is genuinely your app's — its name, where it launches, where payments settle — and the SDK owns the format, so a change to the format arrives with an npm upgrade rather than a diff you have to write: ```ts theme={null} // app/.well-known/bankroll.jwt/route.ts import { manifestRoute, treasuryAddress } from "@joinbankroll/sdk/next"; // Built from the request's own host, so it cannot be prerendered. export const dynamic = "force-dynamic"; export const GET = manifestRoute({ name: () => "Acme Games", launch: "/app", payments: () => treasuryAddress(), supportUrl: () => "https://acme.example/support", appTokens: () => ({ "7Nk3…": { name: "Acme Chips", description: "Play credit for Acme Games" }, }), }); ``` `sub` is taken from the request's own host, so preview deployments, custom domains, and local tunnels each declare themselves correctly with nothing to configure. `payments` returning `null` omits the capability rather than advertising an address the app cannot honor — which is what [`treasuryAddress()`](/build/next#the-treasury) does before a treasury key is set. `supportUrl` and `appTokens` are omitted from the payload entirely when they resolve to nothing, because an empty claim is still a claim and would re-ask every connected user for consent once it gained a value. ### Any other stack A manifest is two Base64URL-encoded JSON segments joined by dots, with an empty signature. There is nothing to sign and no key to hold, so building it by hand is a few lines: ```javascript Node.js (Express) theme={null} const b64url = (o) => Buffer.from(JSON.stringify(o)).toString("base64url"); app.get("/.well-known/bankroll.jwt", (req, res) => { const origin = `https://${req.headers.host}`; const header = { alg: "none", typ: "bankroll-app-manifest+jwt" }; const payload = { manifestVersion: 1, sub: origin, aud: "bankroll-app-host", capabilities: { session: true, payments: process.env.PAYMENT_ADDRESS }, name: "Acme Games", }; res.type("application/jwt").send(`${b64url(header)}.${b64url(payload)}.`); }); ``` ```ruby Ruby (Rails) theme={null} require "base64" require "json" # config/routes.rb: get "/.well-known/bankroll.jwt" => "manifests#show" class ManifestsController < ApplicationController def show b64url = ->(o) { Base64.urlsafe_encode64(JSON.generate(o), padding: false) } header = { alg: "none", typ: "bankroll-app-manifest+jwt" } payload = { manifestVersion: 1, sub: "https://#{request.host}", aud: "bankroll-app-host", capabilities: { session: true, payments: ENV["PAYMENT_ADDRESS"] }, name: "Acme Games" } render plain: "#{b64url[header]}.#{b64url[payload]}.", content_type: "application/jwt" end end ``` ```python Python (FastAPI) theme={null} import base64, json from fastapi import Request, Response def b64url(o: dict) -> str: return base64.urlsafe_b64encode(json.dumps(o).encode()).rstrip(b"=").decode() @app.get("/.well-known/bankroll.jwt") def manifest(request: Request): header = {"alg": "none", "typ": "bankroll-app-manifest+jwt"} payload = { "manifestVersion": 1, "sub": f"https://{request.headers['host']}", "aud": "bankroll-app-host", "capabilities": {"session": True, "payments": os.environ["PAYMENT_ADDRESS"]}, "name": "Acme Games", } return Response(f"{b64url(header)}.{b64url(payload)}.", media_type="application/jwt") ``` Before testing your app, make sure the manifest is live and well-formed — fetch it and decode the middle segment to check your claims: ```bash theme={null} curl https://acme.example/.well-known/bankroll.jwt # → header.payload. ``` # Next.js helpers Source: https://docs.joinbankroll.com/build/next @joinbankroll/sdk/next — the origin, the session on a request, the manifest route, and the treasury. Every Built-for-Bankroll app needs the same few things on the server, and none of them are app-specific: knowing which origin it is served from, verifying the session token on a request, serving the manifest, and holding a treasury. `@joinbankroll/sdk/next` is those, so they are an upgrade rather than a file you maintain. The entry is **server-only** — it imports `next/headers`, which throws in a client bundle, so a mistake fails at build rather than shipping the wrong thing to a browser. Nothing here is required: every helper is a convenience over [`verifyToken`](/build/session#verify-it-on-your-server) and the [manifest](/build/manifest) format, both of which any stack can use directly. ## The origin ```ts theme={null} import { getOrigin } from "@joinbankroll/sdk/next"; const origin = await getOrigin(); // 'https://acme.example' ``` Your app's origin, read from the request's own `host` header — so preview deployments, custom domains, and local tunnels each identify themselves correctly with nothing configured. It is the audience a session token is minted for and the `sub` a manifest claims, which is why it is never guessed: an origin inferred from a deployment environment variable would verify tokens for somewhere the app is not actually served from. `getOrigin()` throws outside a request, so **every route that reaches it must opt out of prerendering**: ```ts theme={null} export const dynamic = "force-dynamic"; ``` The SDK cannot declare that for you — Next reads it only from the route file itself. That covers the manifest route and any route resolving a session. ## The session on a request ```ts theme={null} import { requireSession, requireIdentity, Unauthorized } from "@joinbankroll/sdk/next"; export const dynamic = "force-dynamic"; export async function POST(request: Request) { const session = await requireSession(request); // throws Unauthorized requireIdentity(session); // throws unless verified // session.user.wallet — the user's stable id, and payout target } ``` | Helper | Behavior | | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `getSession(request)` | Reads `x-bankroll-token`, verifies it against Bankroll's public keys with `audience: await getOrigin()`, and returns the [`BankrollSession`](/build/session#the-session) — or `null` for a missing, forged, expired, or wrong-app token. | | `requireSession(request)` | The same, throwing `Unauthorized` instead of returning `null`. | | `requireIdentity(session)` | Throws unless `session.user.identity` is truthy. Real money moves only for a verified person — gate every paid action on it. | Because the audience comes from `getOrigin()`, a token minted for another app can never authenticate a user on yours, and there is no origin constant to keep in step across environments. `Unauthorized` is a plain `Error` subclass — map it to a 401 wherever you handle route errors: ```ts theme={null} try { const session = await requireSession(request); } catch (error) { if (error instanceof Unauthorized) { return Response.json({ error: "unauthorized" }, { status: 401 }); } throw error; } ``` ## The manifest route ```ts theme={null} // app/.well-known/bankroll.jwt/route.ts import { manifestRoute, treasuryAddress } from "@joinbankroll/sdk/next"; export const dynamic = "force-dynamic"; export const GET = manifestRoute({ name: () => "Acme Games", launch: "/app", payments: () => treasuryAddress(), }); ``` `manifestRoute(app)` returns the `GET` handler for `/.well-known/bankroll.jwt`. You supply what is genuinely your app's; it fills in `sub`, `aud`, `manifestVersion`, and the capability shape. | Field | Required | Type | Description | | ------------ | -------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | Yes | `() => string` | Your app's display name, as Bankroll shows it when someone connects. | | `launch` | Yes | `string` | Where the host boots your app — a path resolved against your origin. The origin usually serves a landing page, so without this the host opens the lander. | | `payments` | Yes | `() => string \| null` | The address charges settle to. Returning `null` omits the capability entirely, so an app that hasn't finished setup advertises only what it can honor. | | `supportUrl` | No | `() => string \| null` | Where your users get help. Omitted from the payload when it resolves to nothing. | | `iconDigest` | No | `() => string \| null` | A [Subresource Integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) hash of your icon's exact bytes (`sha256-`). A changed digest is a manifest change, which is what carries a [replaced icon](/build/manifest#updating-your-icon--icondigest) to connected users. Omitted when it resolves to nothing — omit it whenever you serve no icon. | | `appTokens` | No | `() => Record` | The [tokens your app issues](/build/app-tokens), keyed by mint. Omitted when empty — an absent claim means only HSUSD may settle your charges, which is not the same as an empty one. | Each is a function rather than a value because it is evaluated per request: `payments` can start returning an address the moment a treasury key is set, without a redeploy. Entries whose `name` or `description` is an empty string are dropped rather than served, since one invalid entry would take the whole manifest down with it. See [The manifest](/build/manifest) for what each claim means to the host, and for building one on another stack. ## The treasury Your app's one secret is `BANKROLL_TREASURY_KEY`, a base58 Solana secret key. These read it, and are re-exported from `@joinbankroll/sdk/server` too: ```ts theme={null} import { treasuryAddress, requireTreasury } from "@joinbankroll/sdk/next"; treasuryAddress(); // 'J6L3…' — the public address, or null when unset requireTreasury(); // the PaymentSigner, or throws with the setup instruction treasurySigner(); // the PaymentSigner, or null when unset ``` The address is **derived from the secret key**, so the payment address your manifest advertises can never drift from the wallet that actually signs — pass `treasuryAddress` straight to `manifestRoute`'s `payments`. An app runs fine with no treasury: it simply can't take or send money, and says so in its manifest. That is why the accessors answer "not configured" rather than throwing, and only `requireTreasury()` — what a money path calls — fails. `pay()` already defaults to this signer, so a payout needs nothing passed. Reach for `requireTreasury()` when you need the signer itself — a custom instruction, or minting an [app token](/build/app-tokens). See [Paying a user](/build/payouts). # Overview Source: https://docs.joinbankroll.com/build/overview How a Built-for-Bankroll app works — hosting, the manifest, the session token, and payments. Before a **real-money app** can let a user play, it needs the user's age, the user's location, and a way to move money. When Bankroll opens your app inside its mobile app, the host supplies all three: * **Age** — a Bankroll-signed session token carrying the user's handle and their verified age. Verification is performed once, platform-wide, by Bankroll; your app reads the result from the token and applies its own eligibility rules. One person, one account. * **Geo** — the user's region in the same token, as an ISO 3166-2 code (`US-NY`) or a bare country code when only the country resolves. Where you may operate remains your determination. * **Payments** — `charge()` charges the user's Bankroll balance and settles directly to the address fixed in your manifest (`capabilities.payments`); you are not in the deposit path. Settlement is final (no chargebacks) and Bankroll charges no fee. Your side is a web app served from your own origin; the client integration is the [`@joinbankroll/sdk`](https://github.com/inplayinnovation/bankroll-sdk) npm package. ## How it works When Bankroll opens your site, it injects a small host object into your page before it loads. The SDK wraps that host with host detection, token caching, input validation, and typed errors: ```ts theme={null} import { bankroll } from "@joinbankroll/sdk"; bankroll.status(); // 'unavailable' | 'update_required' | 'ready' — sync, SSR-safe bankroll.session(); // Promise — the Bankroll session token scoped to your origin bankroll.charge({ amountCents: 500 }); // Promise — the settled payment's signature ``` Bankroll's app handles the wallet, keys, and signing; your page never constructs a transaction. From a Bankroll deep link — `https://joinbankroll.com/play?url=` — the Bankroll app loads your site in its host webview. The host fetches `/.well-known/bankroll.jwt` — a small manifest (an unsigned JWT) declaring your app's name, the capabilities it uses, and the address that receives payments. It's bound to your app by where it's served, not by a signature. Your icon is served beside it at `/.well-known/bankroll-icon.png`. See [The manifest](/build/manifest). The first time your app opens a session or takes a payment, the user connects it — once, per app. Each charge is still shown to the user to approve; a decline rejects your call with a typed error. Get the session token; charge the user's balance. Verify both on your server before granting anything of value. Under the hood, the host is `window.bankroll` — `{ version, session(), pay() }`, plus [`haptics()`](/build/haptics) and the prerelease [`balances()` and `deposit()`](/build/balances) on a current host, injected before your page loads (the SDK's `charge()` maps to the host's `pay` method — the wire name predates the rename). You can call it directly, but the SDK is the supported surface: it feature-detects each method, so a host too old to have one rejects with `update_required` instead of throwing a `TypeError`, and it normalises every rejection into a typed [`BankrollError`](/build/payments#error-codes). Paying users — winnings, refunds — isn't a host capability: your server transfers HSUSD to the user's wallet address (`session.user.wallet`). See [Paying a user](/build/payouts). Built-for-Bankroll is **open**: publish a valid manifest on your origin and your app runs — no registration or approval to be opened via a `/play` link. Being **bundled** into the Bankroll app itself (featured, first-party placement) is a separate step that requires Bankroll approval. Bankroll is not a gaming regulator and takes no responsibility for your licensing or compliance; operating lawfully in the regions you serve is your responsibility. ## Next SDK, manifest, session verification, a payment, and the deep link. Claims, rules, and serving `/.well-known/bankroll.jwt`. Claims, server-side verification, and eligibility gating. `charge()` input, idempotency, server-side verification, error codes. Payouts and refunds as HSUSD transfers to the user's wallet. The origin, the session on a request, the manifest route, the treasury. Durable JSON with the two guarantees a payment record needs. # Payments Source: https://docs.joinbankroll.com/build/payments Charge the user's Bankroll balance with charge(), then confirm the settled charge on your server. `bankroll.charge()` charges the user's Bankroll balance and settles the funds to the address your [manifest](/build/manifest) fixes in `capabilities.payments`; the page supplies only the amount. ```ts theme={null} import { bankroll } from "@joinbankroll/sdk"; const signature = await bankroll.charge({ amountCents: 500, // whole US cents, > 0 memo: "order:1234", // optional label, see below idempotencyKey: order.paymentKey, // generated once per order }); ``` `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`](#recovering-a-lost-charge) is how you find the charge anyway. ## Input | Field | Type | Description | | ------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `amountCents` | number | The amount to charge, in whole US cents. Must be a positive integer — anything else throws `invalid_amount` before reaching the host. | | `memo` | string | Optional. A short human-readable label attached to the payment (e.g. an order or item id). Trimmed and truncated to 80 characters, and written on-chain so you can [read it back](#reading-the-memo) from the settled transaction. | | `idempotencyKey` | string | Optional. Names the logical payment; retries with the same key never charge twice. Max 255 characters. See [Retries and idempotency](#retries-and-idempotency). | | `reference` | string | Optional. An id your server mints before the charge, which the payment carries on-chain so you can find it later even if this page never reports back. See [Recovering a lost charge](#recovering-a-lost-charge). | | `expiresInSeconds` | number | Optional. How long your price is good for. If approval takes longer, the charge rejects `charge_expired` instead of settling. See [Expiring a stale price](#expiring-a-stale-price). | | `token` | string | Optional. A mint your manifest declares in [`appTokens`](/build/app-tokens) — charge in your own token instead of HSUSD. Anything you haven't declared is refused. | ## 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: ```ts theme={null} const signature = await bankroll.charge({ amountCents: quote.amountCents, expiresInSeconds: 30, // a price that moves; omit for the 90s default idempotencyKey: order.paymentKey, }); ``` 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. ```ts theme={null} // When you create the order, on your server import { createReference } from "@joinbankroll/sdk/server"; const order = await db.order.create({ data: { amountCents: 500, reference: createReference(), // store it with the order paymentKey: crypto.randomUUID(), status: "pending", }, }); ``` ```ts theme={null} // In the page — one extra field const signature = await bankroll.charge({ amountCents: order.amountCents, reference: order.reference, idempotencyKey: order.paymentKey, }); ``` Then sweep orders that never reported back: ```ts Node.js theme={null} import { findChargeByReference } from "@joinbankroll/sdk/server"; for (const order of await db.order.findMany({ where: { status: "pending", createdAt: { lt: minutesAgo(2) } }, })) { const charge = await findChargeByReference(order.reference); if (!charge) continue; // not paid — yet // Same checks as the live path. Nothing about finding it makes it yours. if (charge.payee !== PAYMENT_ADDRESS) continue; if (charge.mint !== HSUSD_MINT) continue; if (charge.amountCents !== order.amountCents) continue; await creditOnce(order, charge.signature); // the signature you never got } ``` **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: ```ts Node.js theme={null} import { confirmCharge, HSUSD_MINT } from "@joinbankroll/sdk/server"; const PAYMENT_ADDRESS = "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin"; // your capabilities.payments const payment = await confirmCharge(signature); // { signature, payer, payee, mint, amountCents, memo, slot } — a return value means it settled if (payment.payee !== PAYMENT_ADDRESS) throw new Error("not paid to your address"); if (payment.mint !== HSUSD_MINT) throw new Error("paid in another asset"); if (payment.amountCents !== order.amountCents) throw new Error("wrong amount"); if (payment.payer !== session.user.wallet) throw new Error("paid by another wallet"); // Mark the signature redeemed (a UNIQUE column) and credit the order in one // database transaction, so the same payment can't be redeemed twice. ``` `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: | `e.code` | What happened | What to do | | -------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `insufficient_funds` | The user can't cover the charge. Bankroll already prompts them to add funds, so you don't need to. | Quietly stop — no error. To offer a top-up on your own terms instead, see [Balances and deposits](/build/balances). | | `payment_denied` | The user declined the charge. | Quietly return them to where they were. | ```ts theme={null} import { bankroll, BankrollError } from "@joinbankroll/sdk"; try { const signature = await bankroll.charge({ amountCents: 500 }); await confirmOnServer(signature); } catch (e) { if (e instanceof BankrollError) { if (e.code === "insufficient_funds" || e.code === "payment_denied") return; } throw e; // anything else is a real failure } ``` ## Error codes All codes `session()` and `charge()` can throw, as `BankrollError.code`: | Code | Thrown by | Meaning | | --------------------------- | --------------- | ------------------------------------------------------------------------------------------------------ | | `unavailable` | session, charge | Not in a Bankroll host (mirrors `status()`). | | `update_required` | session, charge | The Bankroll app is too old for this SDK (mirrors `status()`). | | `payment_denied` | charge | The user declined the charge. | | `consent_declined` | session, charge | The user declined connecting your app. | | `verification_declined` | session | `session({ identity: true })` — the user didn't complete verification. | | `insufficient_funds` | charge | Balance too low — Bankroll already prompts the user to add funds. | | `invalid_amount` | charge | `amountCents` isn't a positive integer. | | `capability_not_registered` | session, charge | The capability isn't declared in your [manifest](/build/manifest). | | `manifest_error` | session, charge | Your `/.well-known/bankroll.jwt` is missing or malformed. | | `idempotency_conflict` | charge | The `idempotencyKey` was already used with different parameters. | | `charge_expired` | charge | Approval took longer than [`expiresInSeconds`](#expiring-a-stale-price) — nothing was signed or moved. | | `unknown` | any | An unmapped host reason — the original message is preserved. | 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. # Paying a user Source: https://docs.joinbankroll.com/build/payouts Pay users — winnings, payouts, refunds — with pay(), an HSUSD transfer from your treasury to the user's wallet. `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](/build/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. ```ts Node.js theme={null} import { pay } from "@joinbankroll/sdk/server"; // env: BANKROLL_TREASURY_KEY (base58 secret key), SOLANA_RPC_URL (optional) const { signature } = await pay({ to: session.user.wallet, amountCents: 2500, memo: "payout:order-1234", // optional on-chain label }); ``` `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: ""` to pay out one of your own [app tokens](/build/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: ```ts Node.js theme={null} import { pay } from "@joinbankroll/sdk/server"; import { privySigner } from "@joinbankroll/sdk/privy"; // env: PRIVY_APP_ID, PRIVY_APP_SECRET, PRIVY_WALLET_ID const signer = await privySigner({ idempotencyKey: `payout-${orderId}` }); const { signature } = await pay({ to: session.user.wallet, amountCents: 2500 }, { signer }); ``` 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: ```ts Node.js theme={null} import { pay, type PaymentSigner } from "@joinbankroll/sdk/server"; const signer: PaymentSigner = { address: TREASURY_ADDRESS, // the wallet's public address sendTransaction: async (txBase64) => { // Sign and broadcast the base64 wire transaction with your wallet // service; resolve with the transaction signature. return signature; }, }; ``` 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. ```ts Node.js theme={null} import { buildAndSignPayout, sendPayout, confirmPayout } from "@joinbankroll/sdk/server"; // 1. Build and sign — nothing broadcast yet. const signed = await buildAndSignPayout({ to: session.user.wallet, amountCents: 2500, memo: "payout:order-1234" }); // { transaction (base64, signed), signature, lastValidBlockHeight, blockhash } // 2. Store BEFORE you send: persist signed.signature + signed.lastValidBlockHeight // on the payout row in the same write that marks it submitted. // 3. Send. A failure here settles nothing either way — the outcome is now // always a question the chain can answer about the stored signature. await sendPayout(signed.transaction); // 4. Confirm — now, or later from a sweep — against the STORED signature. await confirmPayout(signed.signature, { lastValidBlockHeight: signed.lastValidBlockHeight }); ``` 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. | `PayError.code` | What happened | Safe to retry? | | ---------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `send_failed` | The RPC rejected that submission (e.g. insufficient funds) — it sent nothing. | Yes for a freshly built transaction. A resend of stored bytes proves nothing about the past — a duplicate that already landed also rejects here — so judge by the stored signature. | | `expired` | The transaction provably died unused (its blockhash expired). | Yes. | | `failed_on_chain` | It landed but failed — no funds moved. | Yes, after fixing the cause. | | `rpc_error` | An RPC request failed; if it failed before the broadcast nothing was sent. | Check the error's `signature`: absent means nothing was sent. Present means the outcome is unknown — treat it like `confirmation_timeout`. | | `confirmation_timeout` | It was broadcast but the outcome is unknown — it may still land. | **No** — look up the error's `signature` on-chain first. | 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. # Quickstart Source: https://docs.joinbankroll.com/build/quickstart Install the SDK, serve a manifest, verify a session, take a payment, and open the app with a deep link. A Built-for-Bankroll app is a web app served from your own HTTPS origin — any framework. ## Step 1 — Install the SDK and detect the host ```bash theme={null} npm install @joinbankroll/sdk ``` Your app will run in three places: a plain browser, an outdated Bankroll app, and a current Bankroll host. `bankroll.status()` tells you which — synchronous, safe anywhere, including SSR (always `'unavailable'` on the server). ```ts theme={null} import { bankroll } from "@joinbankroll/sdk"; switch (bankroll.status()) { case "unavailable": // not inside Bankroll — show "Get the Bankroll app" case "update_required": // Bankroll app too old — show "Update the Bankroll app" case "ready": // session() and charge() will work } ``` ## Step 2 — Serve your manifest Serve a manifest at **`/.well-known/bankroll.jwt`** on your app's origin. It declares your app's name and icon, the capabilities it uses, and the address that receives payments. It's an **Unsecured JWT** (`alg: none`, empty signature) — Bankroll binds it to your app by fetching it from your origin, so there's no key to manage. On Next.js, `manifestRoute()` is the whole route — you declare what's yours and the SDK owns the format: ```ts app/.well-known/bankroll.jwt/route.ts (Next.js) theme={null} import { manifestRoute } from "@joinbankroll/sdk/next"; export const dynamic = "force-dynamic"; // built from the request's own host export const GET = manifestRoute({ name: () => "Acme Games", launch: "/app", payments: () => process.env.PAYMENT_ADDRESS ?? null, }); ``` On any other stack, build the two Base64URL segments yourself — see [The manifest](/build/manifest#any-other-stack) for Node, Ruby, and Python. Bankroll fetches this over HTTPS — if it's missing or malformed, your app won't be granted any capability. To show an icon, also serve a square PNG at `/.well-known/bankroll-icon.png`. See [The manifest](/build/manifest) for every claim and rule. ## Step 3 — Identify the user The user's verified session travels as a signed token in the `x-bankroll-token` header. The simplest way to send it is to decorate `fetch` once: ```ts Client theme={null} import { withBankrollToken } from "@joinbankroll/sdk"; const appFetch = withBankrollToken(fetch); // Every request now carries the session token. In a plain // browser the request goes out bare and your server responds 401. await appFetch("/api/session", { method: "POST" }); ``` Or attach it manually — `bankroll.session()` resolves the token (the SDK caches it and re-mints before expiry, so repeated calls are cheap): ```ts Client (manual) theme={null} import { bankroll, BANKROLL_TOKEN_HEADER } from "@joinbankroll/sdk"; const token = await bankroll.session(); await fetch("/api/session", { method: "POST", headers: { [BANKROLL_TOKEN_HEADER]: token }, }); ``` On your server, **verify the token** — never trust an unverified token from the client. `session.user.wallet` is the user's stable id. On Next.js this is one call, with the audience taken from the request's own host so there's no origin constant to keep in step — see [Next.js helpers](/build/next): ```ts app/api/session/route.ts (Next.js) theme={null} import { requireSession } from "@joinbankroll/sdk/next"; export const dynamic = "force-dynamic"; export async function POST(request: Request) { const session = await requireSession(request); // throws Unauthorized return Response.json({ wallet: session.user.wallet }); } ``` On any other stack, verify it directly: ```javascript Node.js theme={null} import { verifyToken } from "@joinbankroll/sdk/server"; import { BANKROLL_TOKEN_HEADER } from "@joinbankroll/sdk"; // Your app's exact origin — the token is scoped to it. const MY_ORIGIN = "https://acme.example"; export async function requireBankrollSession(req) { const session = await verifyToken(req.headers.get(BANKROLL_TOKEN_HEADER), { audience: MY_ORIGIN, }); // null covers everything: missing header, bad signature, expired, // or a token minted for a different app. if (!session) return null; return session; // BankrollSession — { ..., geo?, user: { wallet, username, identity } } } ``` ```ruby Ruby theme={null} require "jwt" require "net/http" require "json" JWKS_URI = "https://joinbankroll.com/.well-known/jwks.json" MY_ORIGIN = "https://acme.example" # Cache the key set — don't refetch on every request. def bankroll_jwks @bankroll_jwks ||= JWT::JWK::Set.new(JSON.parse(Net::HTTP.get(URI(JWKS_URI)))) end # token = request.headers["x-bankroll-token"] def verify_bankroll_token(token) payload, = JWT.decode( token, nil, true, algorithms: ["RS256"], iss: "https://joinbankroll.com", verify_iss: true, aud: MY_ORIGIN, verify_aud: true, jwks: bankroll_jwks ) payload # { "sub" => ..., "username" => ..., ... } end ``` ```python Python theme={null} import jwt from jwt import PyJWKClient JWKS_URI = "https://joinbankroll.com/.well-known/jwks.json" MY_ORIGIN = "https://acme.example" _jwks = PyJWKClient(JWKS_URI) # token = request.headers["x-bankroll-token"] def verify_bankroll_token(token: str) -> dict: key = _jwks.get_signing_key_from_jwt(token).key return jwt.decode( token, key, algorithms=["RS256"], issuer="https://joinbankroll.com", audience=MY_ORIGIN, ) ``` Verify the `audience` equals **your** origin, byte-for-byte: `https`, lowercase host, no default port, no trailing slash (e.g. `https://acme.example`). The token is scoped to your app; a token minted for someone else's app must not authenticate a user on yours. See [The session token](/build/session) for every claim and what it means. ## Step 4 — Charge the user Call `bankroll.charge()` with an amount in whole US cents. Bankroll shows the user the charge, moves the funds to the address your manifest fixes in `capabilities.payments`, and resolves with the settled payment's signature. The call cannot name a recipient. ```ts Client theme={null} import { bankroll, BankrollError } from "@joinbankroll/sdk"; async function buy(orderId: string, amountCents: number) { try { const signature = await bankroll.charge({ amountCents, memo: `order:${orderId}`, }); // Send the signature to your server to confirm before granting the item. await fetch("/api/orders", { method: "POST", body: JSON.stringify({ orderId, signature }), }); } catch (e) { if (e instanceof BankrollError) { if (e.code === "insufficient_funds") return; // Bankroll already prompts to add funds if (e.code === "payment_denied") return; // user declined the charge if (e.code === "consent_declined") return; // user declined connecting the app } throw e; } } ``` Every failure is a `BankrollError` with a stable snake\_case `code`. Handle `insufficient_funds`, `payment_denied`, and `consent_declined` as shown above without surfacing an error; propagate anything else. On your server, confirm the returned signature settled the expected amount to your payment address before granting value. See [Payments](/build/payments). ## Step 5 — Launch your app Users open your app with a Bankroll deep link — your app's URL, URL-encoded: ``` https://joinbankroll.com/play?url=https%3A%2F%2Facme.example%2F ``` The SDK builds it for you: ```ts theme={null} import { playLink } from "@joinbankroll/sdk"; playLink("https://acme.example/"); // → https://joinbankroll.com/play?url=https%3A%2F%2Facme.example%2F ``` When a *user* shares that link rather than you, pass their wallet and Bankroll credits them for anyone new who joins through it — see [Share links](/build/share-links): ```ts theme={null} playLink("https://acme.example/", { referrer: session.user.wallet }); ``` Opening that link on a device with Bankroll installed launches the app and loads your site in the host webview. The URL must be **HTTPS**, and its origin must resolve to a valid manifest (Step 2). ## Step 6 — Test the round trip 1. Open your `/play` deep link on a device signed into Bankroll. 2. Your site loads; your first `session()`/`charge()` call runs. 3. `session()` returns a token your server verifies (Step 3). 4. `charge()` returns a signature your server confirms (Step 4). ## Going live No registration or approval is required: once your manifest is served, your `/play` link opens your app for any Bankroll user. To pay users back — winnings, refunds — see [Paying a user](/build/payouts). Being **bundled** in the Bankroll app itself (featured, first-party placement) is a separate step that requires Bankroll approval — ask in the [Built for Bankroll Discord](https://discord.gg/FH3BbAM7t6). # React helpers Source: https://docs.joinbankroll.com/build/react @joinbankroll/sdk/react — host status that doesn't flash the wrong screen, a token-carrying fetch, and a development overlay. `@joinbankroll/sdk/react` is the client-side glue that behaves identically in every app: reading host status without flashing the wrong screen through hydration, a `fetch` that carries the session token, and a development overlay. Your app's own product surface stays in your app. `react` is an optional peer (`>= 18`), installed only if you use this entry. ## Host status ```tsx theme={null} import { useBankrollStatus, useBankrollChecked } from "@joinbankroll/sdk/react"; function Gate({ children }: { children: React.ReactNode }) { const checked = useBankrollChecked(); const status = useBankrollStatus(); if (!checked) return ; if (status === "unavailable") return ; if (status === "update_required") return ; return children; } ``` `useBankrollStatus()` is [`bankroll.status()`](/build/quickstart#step-1--install-the-sdk-and-detect-the-host) as a hook. The host is injected before your page loads and never changes afterwards, so there is nothing to subscribe to — the work these do is agreeing across the hydration boundary. `useBankrollChecked()` is why the pair exists. It is `false` during the server render and the first client paint, and `true` after. **Render a loading state on it rather than deciding**, or a phone already inside Bankroll sees "get the Bankroll app" until hydration corrects it. Server and client deliberately agree on `'unavailable'` for the same reason: the wrong screen briefly is worse than no screen briefly. ## Fetch with the session token ```ts theme={null} import { bankrollFetch } from "@joinbankroll/sdk/react"; await bankrollFetch("/api/orders", { method: "POST", body }); ``` [`withBankrollToken(fetch)`](/build/session#send-it-to-your-server), already built and bound. Binding matters — `fetch` throws when called detached from the window — and on the server it resolves to the bare global, so importing this module never crashes a render that doesn't use it. In a plain browser the request goes out bare and your server answers 401. ## Sending a user through verification ```ts theme={null} import { verifyIdentity } from "@joinbankroll/sdk/react"; if (!(await verifyIdentity())) return; // declined — leave them where they are ``` `session({ identity: true })` reduced to the question a UI actually asks: `true` once the user is verified, `false` if they declined or the host refused. Anything that is not a host rejection propagates. Use it for an explicit "verify to continue" button; for gating a paid action, check [`session.user.identity`](/build/session#gating-eligibility) on your server, which is the only place the answer is trustworthy. ## Development overlay ```tsx theme={null} import { DevTools } from "@joinbankroll/sdk/react"; {process.env.NODE_ENV === "development" && ( )} ``` A floating panel reporting how the app is configured — the manifest's origin, the treasury address, the RPC endpoint, whatever you pass. Rows marked `ok: false` render in amber, and `copy: true` truncates the display while putting the **full** value on the clipboard, since a shortened address you can't copy is useless. Render it only when you mean to. It carries addresses and endpoints, so gate it on your own development check — the component does not gate itself. | Field | Type | Description | | --------- | --------- | ----------------------------------------------------------------- | | `label` | `string` | Row label, and the key. | | `value` | `string` | The full value — what gets copied. | | `ok` | `boolean` | `false` renders the value as a warning. | | `display` | `string` | Shown instead of `value` when the real thing is too long to read. | | `copy` | `boolean` | Offer a copy button; `value` is what lands on the clipboard. | In a Next.js app the overlay also hides Next's own dev badge, which otherwise stacks with it in a phone-sized viewport, and offers it back behind a toggle. # The session token Source: https://docs.joinbankroll.com/build/session The Bankroll session token — claims, server-side verification, and eligibility gating. `bankroll.session()` resolves the **Bankroll session token** — a signed JWT scoped to your app that identifies the current user. Send it to your server in the `x-bankroll-token` header and verify it there before trusting anything in it. ```ts theme={null} import { bankroll } from "@joinbankroll/sdk"; const token = await bankroll.session(); ``` The first call may ask the user to connect your app; later calls resolve without re-prompting. Tokens are short-lived (\~15 minutes) — the SDK caches the current one and mints a fresh one before it expires, and concurrent calls share one mint, so repeated calls are cheap. ## Requiring a verified user ```ts theme={null} const token = await bankroll.session({ identity: true }); // resolves only for a verified user — session.user.identity is truthy ``` `session({ identity: true })` resolves only once the user has been verified by Bankroll. A person can verify only once, so a verified session maps to one real person. That supports per-person — rather than per-account — enforcement on your side: * **Multi-accounting** — a second account for the same person can't produce a second verified session. * **Limits** — deposit, loss, and time limits keyed to a person, not an account. * **Eligibility & AML** — verified age and region as inputs to your own compliance checks. If the user isn't verified yet, Bankroll runs them through verification and resolves once they finish; if they don't complete it, the promise rejects with `verification_declined`. Plain `session()` works before verification — use it for free-to-play, and require `{ identity: true }` before real-money play if your rules call for it. An older Bankroll app that can't run verification rejects `{ identity: true }` with `update_required`. ## Send it to your server The token travels in the `x-bankroll-token` header. Decorate `fetch` once and every request carries it: ```ts theme={null} import { withBankrollToken } from "@joinbankroll/sdk"; const appFetch = withBankrollToken(fetch); await appFetch("/api/session", { method: "POST" }); ``` In a plain browser (status `'unavailable'`) requests go out bare and your server responds 401. Any other failure — including the user declining — propagates. ## Verify it on your server Never trust a session token that hasn't been verified. In Node, verify with `verifyToken`: ```ts theme={null} import { verifyToken } from "@joinbankroll/sdk/server"; import { BANKROLL_TOKEN_HEADER } from "@joinbankroll/sdk"; const session = await verifyToken(req.headers.get(BANKROLL_TOKEN_HEADER), { audience: "https://acme.example", // your app's exact origin }); if (!session) { return Response.json({ error: "unauthorized" }, { status: 401 }); } // session.user.wallet is the user's stable id — bind it to your session. ``` It checks the RS256 signature against Bankroll's public keys, the issuer, and that the token was minted for your origin. It accepts `null`/`undefined` and returns `null` on any failure, so one `if (!session)` covers missing, forged, expired, and wrong-app tokens alike. On other stacks, verify the JWT directly: | What | Value | | ------------------ | --------------------------------------------------- | | Algorithm | `RS256` | | Public keys (JWKS) | `https://joinbankroll.com/.well-known/jwks.json` | | Issuer (`iss`) | `https://joinbankroll.com` | | Audience (`aud`) | **Your app's origin** (e.g. `https://acme.example`) | Always check `aud` equals your own origin, byte-for-byte — `https`, lowercase host, no default port, no trailing slash. The token is scoped to your app, so a token minted for a different app must not sign a user into yours. Also fetch the keys from the JWKS URL and cache them — never hard-code a key. See the [Quickstart](/build/quickstart#step-3--identify-the-user) for full verification snippets in Node.js, Ruby, and Python. ## The session A verified token gives you the session and the user inside it: ```ts theme={null} // what verifyToken returns interface BankrollSession { iss: string; // issuer — always https://joinbankroll.com aud: string; // your app's origin iat: number; // issued-at (seconds) exp: number; // expiry (seconds) — tokens are short-lived (~15 min) geo?: string; // this session's region — 'US-NY' (ISO 3166-2) or 'US' user: { wallet: string; // the user's wallet address — their stable id, and payout target username: string; // the user's Bankroll handle — always present identity: { age?: number } | false; // verified ({} if no date of birth on file), or false }; } ``` The **session** carries the envelope and the request's `geo` — the user's location for this session, not their residence. The **user** fields are durable per-person attributes. | Field | Where | Meaning | | ----------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `iss` / `aud` / `iat` / `exp` | session | Standard JWT envelope. `iss` is always `https://joinbankroll.com`; `aud` is your origin. | | `geo` | session | The user's region for this session — an ISO 3166-2 code (e.g. `US-NY`), or a bare country code (`US`) when only the country is known. Omitted if unresolvable. | | `user.wallet` | user | The user's wallet address. Use it as their primary key on your platform, and as the destination for [payouts](/build/payouts). | | `user.username` | user | The user's Bankroll handle. **Always present** — the host guarantees one, so you never special-case a handle-less user. | | `user.identity` | user | Verification state, always present. **`{ age }`** — verified; `age` is present when a date of birth is on file. **`false`** — not verified (pending, never started, or rejected). Truthy ⟺ one verified real person. | **Wire names:** in the raw JWT, `user.wallet` is the `sub` claim and `user.identity` is delivered as `kyc`. The SDK exposes the friendly names; if you verify raw JWTs on another stack, read `sub` and `kyc` (treating an absent `kyc` as not-verified). ## Gating eligibility Real-money eligibility is a **server-side** decision, computed from the verified session — never from an unverified client value: * **`session.user.identity`** (truthy) — the user has cleared verification. `false` means not verified. * **`session.user.identity.age`** — the user's verified age, for age-restricted play. * **`session.geo`** — the user's region for this session, for where-you-can-play rules. ```ts theme={null} const session = await verifyToken(token, { audience: MY_ORIGIN }); const canPlayRealMoney = session !== null && session.user.identity !== false && (session.user.identity.age ?? 0) >= 21 && ALLOWED_REGIONS.has(session.geo ?? ""); ``` ## Handling a decline `session()` rejects with a `BankrollError` whose code is `consent_declined` if the user declines the connection; `session({ identity: true })` rejects `verification_declined` if they back out of verification. Catch either and show your own "connect to continue" state rather than surfacing an error. ```ts theme={null} import { bankroll, BankrollError } from "@joinbankroll/sdk"; try { const token = await bankroll.session(); // ...verify server-side, sign the user in } catch (e) { if (e instanceof BankrollError && e.code === "consent_declined") { showConnectPrompt(); // let them try again return; } throw e; } ``` # Share links Source: https://docs.joinbankroll.com/build/share-links Mint a link that opens your app inside Bankroll, and credit the user who shared it under Bankroll's referral program. `playLink()` builds the link that opens your app inside Bankroll. Pass a `referrer` and it does a second job: whoever opens it, if they're new to Bankroll, is attributed as that user's referral. ```ts theme={null} import { playLink } from "@joinbankroll/sdk"; const link = playLink("https://acme.example/game", { referrer: session.user.wallet, // the user doing the sharing }); // → https://joinbankroll.com/play?url=https%3A%2F%2Facme.example%2Fgame&ref=… ``` That's the whole integration. You don't run a referral program, hold a ledger, or pay anyone — Bankroll's existing program does it, and your app gets the player. ## What the referrer is **The wallet of the Bankroll user sharing the link**, from a verified [session](/build/session). Not a referral code: codes are the `XXX-XXX` shape built for a person to read aloud or type into a box, and there's no person in this loop. Your app already holds the wallet, and it's the canonical user id, so there's nothing extra to fetch. ## Who can be credited Bankroll's referral program is **person to person**. The referrer has to be a real Bankroll user's wallet, so: * Your app's **treasury address doesn't work**. It's a keypair your app generated; no Bankroll account stands behind it, so it's attributed to nobody and the link still opens normally. * An **unrecognized or malformed** `referrer` costs the attribution, never the link. There's no error to handle — the app opens either way. * **Self-referral is refused**, and a user can be referred at most once, ever. ## The program, in numbers | | | | -------------------- | ------------------------------------------------------------------------------------------------------------------------- | | New user | attributed at signup; referred at most once | | Qualifies by | \$30 of HSUSD play across [verified apps](/build/verified), within 30 days | | New user gets | 1,000 points (\$10) on qualifying | | Referrer gets | 1,000 points (\$10), first 30 qualifying referrals per month | | Your app gets | \$10 HSUSD to your treasury per qualifying referral from your links, once [verified](/build/verified) — first 100 a month | | Paid and notified by | Bankroll — both sides get a push | Play in your app counts once it's [verified](/build/verified). Terms are stamped when the link is opened — Bankroll can change them for future referrals, so keep amounts in one place in your copy. ## What to tell your users > Invite a friend to Bankroll. They get $10 after their first $30 of play — > and you get \$10 too. No reward for signing up alone, for play in unverified apps, or for a friend who already has an account. Unverified app? Say "play on Bankroll", not "play here". ## Where the link can be opened A `/play` link works the same everywhere, which is worth knowing before you put one in a tweet: * **Phone, Bankroll installed** — opens your app in the host. * **Phone, no Bankroll** — verifies a phone number, then sends them to install. Your link is remembered for 24 hours and they land in your app after signup — and that's where the referral attaches. * **Desktop** — shows a QR that reopens the same link, referrer intact, on a phone. The referrer survives every one of those hops, so a link scanned off a laptop attributes exactly like one tapped on a phone. ## The referrer's notification When an attribution lands, Bankroll notifies the referrer that their link was used. The copy is Bankroll's and doesn't name your app — from their side the news is that someone accepted their invite, and which app the friend picked isn't yours to broadcast. There's nothing to configure and no webhook to handle. # App status & offers Source: https://docs.joinbankroll.com/build/status An optional endpoint the Bankroll app reads to decorate your tile — a ring and a headline when you have something on offer for this user. Bankroll reads `https:///.well-known/bankroll-status` when it shows your tile. Answer with what you have on offer for the person asking and the tile gets a ring and your headline; answer nothing and it doesn't. That is the whole effect: the document is decorative, per user, per moment, and grants nothing. ## The request * `GET /.well-known/bankroll-status`, no ambient credentials, at most five same-origin redirects, **2 seconds**, `application/json`, body **≤ 1 KiB**. Anything else — slow, large, wrong type, unparseable — reads as "no offer", never as an error. * If the user has already connected your app, the request carries `Authorization: Bearer ` naming the user, scoped to your origin — verify it with `verifyToken(token, { audience: origin })` from `@joinbankroll/sdk/server` and answer for that wallet. It identifies; don't treat it as a login. **No token means a user who hasn't connected you yet — respond with your generic new-user offer** (a welcome bonus, a first-deposit match), the same answer for everyone. * Per-user answers must not be cached: send `cache-control: no-store`. ## The response ```json theme={null} { "offers": [{ "headline": "Double your first deposit", "key": "cmszndqd20000l204ga84lvgq" }] } ``` `offers` is an ordered array; Bankroll shows the **first** entry and ignores the rest (keep it to a few — the whole body must stay under 1 KiB). The app asks once each time the Home screen mounts, draws a ring on your tile for the first offer, and clears it when the tile is tapped. An entry is an object with two optional members, or `true` for an offer with no details: | Member | | | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `headline` | ≤ 64 characters, drawn as a one-line banner on the tile; aim for \~30 so it fits. Over the limit it is **dropped, not truncated**. | | `key` | ≤ 64 characters, opaque to Bankroll. When the user opens your app from the ringed tile it comes back as `?offer=` on your launch URL, so your app knows which offer they tapped. It doesn't change where the app opens. | Absent, `null`, `[]`, `false`, or anything unrecognized means no offer. ## Example ```ts theme={null} // app/.well-known/bankroll-status/route.ts import { verifyToken } from "@joinbankroll/sdk/server"; export const dynamic = "force-dynamic"; export async function GET(request: Request) { const auth = request.headers.get("authorization"); const origin = `https://${request.headers.get("host")}`; const session = auth?.startsWith("Bearer ") ? await verifyToken(auth.slice(7), { audience: origin }) : null; const offers = session ? await offersFor(session.user.wallet) // this user's open offers, best first : await newUserOffer(); // no token: the generic new-user offer return Response.json({ offers }, { headers: { "cache-control": "no-store" } }); } ``` Nothing here is load-bearing: a bad token, a slow query, or an empty answer costs the user a ring, not a session. Don't put state your app depends on behind it, and don't tell an anonymous caller anything you wouldn't print on a poster. # The store Source: https://docs.joinbankroll.com/build/store @joinbankroll/sdk/store — durable JSON with an atomic create and compare-and-swap, so a payment can't be redeemed twice. No database to provision. A Built-for-Bankroll app has to remember what it sold, and the record that matters most is *"this payment signature is already spent"* — a fact two concurrent requests must never both succeed at writing. `@joinbankroll/sdk/store` is a small durable-JSON interface with exactly the two guarantees that needs, and two backends behind it: local files while you develop, object storage once you deploy. It is entirely optional. If you already run a database, use it — a `UNIQUE` column on the signature is the same guard. This exists so an app can take its first payment safely without provisioning anything. Notice what the interface does **not** have: a balance. A Bankroll app never holds anyone's money — the host is the wallet. The app sells things and remembers what was sold. ## Pick a backend ```ts theme={null} import { fsBackend } from "@joinbankroll/sdk/store/fs"; import { vercelBlobBackend } from "@joinbankroll/sdk/store/vercel"; export const store = process.env.BLOB_READ_WRITE_TOKEN ? vercelBlobBackend() : fsBackend(); ``` Three subpaths, so a backend is opt-in: `./store` is pure interface, `./store/fs` imports only Node builtins, and `./store/vercel` is the only module that touches `@vercel/blob` — an optional peer (`>= 2.3.0`), installed only if you use it. Nothing written against `StoreBackend` knows which is live, which is what lets the same code deploy unchanged. | Backend | Where it stores | Setup | | --------------------- | -------------------------------------------------- | ----------------------------------------------------------- | | `fsBackend(root?)` | `bankroll//` under the working directory | None. | | `vercelBlobBackend()` | A private Vercel Blob store | Connect a Blob store to the project; the token is injected. | The filesystem root carries the environment segment for a reason: without it a test run and the app you are developing share one directory, and the obvious fixture — clear the store before each case — deletes live data. That is worse than losing dev state, because removing a spent-signature record turns an already-spent payment back into a spendable one. ## The two guarantees ### `createIfAbsent` — record a payment exactly once An atomic create that fails if the path is taken. Losing that race is an expected outcome, not an error, so it returns `false` rather than throwing — real failures (network, permissions) still propagate. ```ts theme={null} import { confirmCharge, HSUSD_MINT } from "@joinbankroll/sdk/server"; import { sortableId } from "@joinbankroll/sdk/store"; const payment = await confirmCharge(signature); if (payment.payee !== PAYMENT_ADDRESS) throw new Error("not paid to your address"); if (payment.mint !== HSUSD_MINT) throw new Error("paid in another asset"); if (payment.amountCents !== order.amountCents) throw new Error("wrong amount"); const id = sortableId(payment.slot, signature); const first = await store.createIfAbsent(`purchases/${id}.json`, { wallet: session.user.wallet, amountCents: payment.amountCents, item: order.item, }); if (!first) return alreadyGranted(id); // replay — do not grant twice ``` This is the replay guard from [Payments](/build/payments#confirm-on-your-server), and it holds without a second document to coordinate it: the id is built from the transaction, so every retry of the same payment computes the same path. ### `writeJson` with `ifMatch` — compare-and-swap A conditional write that lands only if the stored etag still matches, and throws `PreconditionFailed` otherwise, so a concurrent writer can't clobber a transition. `updateJson` is the read-modify-write loop over it: ```ts theme={null} import { updateJson, DocumentNotFound, TooContended } from "@joinbankroll/sdk/store"; const next = await updateJson(store, `matches/${matchId}.json`, (match) => { if (match.status !== "open") throw new Error("already settled"); return { ...match, status: "settled", winner }; }); ``` `change` sees the current value and returns the next one, or throws to abort without writing. On a lost race it re-reads and decides again against the winner's value, which is what makes "do this exactly once" hold. It gives up after 5 attempts by default (`{ attempts }`). | Thrown | Meaning | | -------------------- | ---------------------------------------------------- | | `DocumentNotFound` | Nothing at that path — a caller mistake. | | `TooContended` | The swap never settled within `attempts` — load. | | `PreconditionFailed` | From `writeJson` directly: someone else wrote first. | ## Reading and listing ```ts theme={null} const stored = await store.readJson(`purchases/${id}.json`); // { value, etag } — or null. Never served stale. const page = await store.list("purchases/", { limit: 20 }); // { items, cursor? } — cursor absent once the prefix is exhausted ``` `list` returns a page in **ascending key order**, and only the page — never the whole prefix, which is what keeps it cheap at any size. A caller that wants time order puts a sortable value at the front of the key rather than sorting after the fact. That is what `sortableId(slot, signature)` is for. A charge's slot is a chain-assigned, monotonic number; the id inverts it so an object store's lexicographic ordering comes back **newest-first** with no post-sort, and puts the signature after it so a document is only ever addressable by its full id: ```ts theme={null} sortableId(298_000_123, "5Zx…"); // '9007198956740868-5Zx…' sortableId(298_000_456, "9Ab…"); // '9007198956740535-9Ab…' — later slot, sorts first ``` Listing returns contents, but at a read per document — neither backend returns them in the listing itself. That is inherent to asking for a collection, not a gap: the alternative is an index document, a second thing to keep in step, which is exactly what this store is shaped to avoid. ## Interface ```ts theme={null} interface StoreBackend { readJson(pathname: string): Promise<{ value: T; etag: string } | null>; writeJson(pathname: string, value: unknown, ifMatch?: string): Promise; createIfAbsent(pathname: string, value: unknown): Promise; list( prefix: string, options?: { limit?: number; cursor?: string }, ): Promise<{ items: T[]; cursor?: string }>; } ``` Both backends satisfy the same conformance suite, so behavior that holds in development holds in production. Two details worth knowing: * **Reads in the money path are never cached.** The Blob backend passes `useCache: false`, because a CDN serving a document up to 60s stale would make read-modify-write unsafe. * **A lost `createIfAbsent` whose response was lost reads as taken**, which refuses a payment you already recorded rather than granting it twice. That is the safe direction to be wrong in. # Bankroll Verified Source: https://docs.joinbankroll.com/build/verified What the green check means, what it unlocks, and how an app gets it. A green **✓** after an app's name — on its tile, the connect sheet, and the pay sheet — means Bankroll has reviewed what that origin serves and signed its manifest. Users hear it as "Verified by Bankroll". ## What it means * The name, icon, and payment recipient the user sees are the ones Bankroll attested — the origin can't change them without Bankroll signing again. * It does not mean Bankroll audited your game, your odds, or your code, and it changes nothing about capabilities or grants: an unverified app gets the same bridge, the same session, the same `charge()`. ## What it unlocks * **Referrals** — play in your app counts toward a friend's \[qualifying $30](/build/share-links), and your treasury earns $10 per qualifying referral from your links (first 100 a month). * **Push** — the push channel only accepts apps with a signed manifest. ## How you get it Ask in the [Built for Bankroll Discord](https://discord.gg/FH3BbAM7t6). Bankroll fetches what your origin serves at `/.well-known/bankroll.jwt` and `/.well-known/bankroll-icon.png`, reviews every claim, and hands you a signed JWT. Then: Set `BANKROLL_SIGNED_MANIFEST` to the signed JWT; the SDK's `manifestRoute` serves it byte-for-byte. The signature covers the exact bytes, so nothing may touch it. `manifestRoute` answers `?signing=1` with your freshly built manifest, so a later change can be re-signed without unpublishing the current one. Any claim change — name, launch path, payments, the icon — needs a new signature. Until then the old one stays valid and the new claims aren't live. The signature is bound to one exact origin. Preview deployments and other hosts serving the same JWT are not verified there — they fall back to the unsigned baseline, which works, just without the check. # Built for Bankroll Source: https://docs.joinbankroll.com/index Run a real-money web app inside Bankroll — verified age, geolocation, and payments supplied by the host. A Built-for-Bankroll app is a web app served from your own origin, opened inside the Bankroll mobile app. The host supplies the three rails a real-money app needs: * **Age** — a verified age for each user, delivered in a signed session token you verify server-side, ready to feed your own eligibility rules. One person, one account, so the same human can't hold two. * **Geo** — the user's region (ISO 3166-2) in the same token when resolvable, for per-region eligibility rules. * **Payments** — one call charges the user's balance and settles to the address fixed in your manifest. Settlement is final — no chargebacks — and Bankroll charges no fee. Users arrive signed in to Bankroll; `session()` identifies them to your server, so there is no separate login flow. The integration is one npm package, a manifest at `/.well-known/bankroll.jwt`, and two calls: ```bash theme={null} npm install @joinbankroll/sdk ``` ```ts theme={null} import { bankroll } from "@joinbankroll/sdk"; const token = await bankroll.session(); // signed session token: user, identity, geo const signature = await bankroll.charge({ amountCents: 500 }); // charge $5.00; resolves with the settled signature ``` Questions, verification, or a deal to discuss — the team is in the [Built for Bankroll Discord](https://discord.gg/FH3BbAM7t6). How hosting, the manifest, the session token, and payments fit together. SDK, manifest, session verification, a payment, and the deep link. Claims, rules, and serving `/.well-known/bankroll.jwt`. Claims, server-side verification, and eligibility gating. `charge()` input, idempotency, server-side verification, error codes. Payouts and refunds as HSUSD transfers to the user's wallet.