# sub3: wallet sign-in and stablecoin payments for any app People: the step-by-step guide is at https://sub3.dev/docs, and the frozen specification at https://sub3.dev/spec. > The integration guide for coding agents and the people working with them. No SDK, no API key, no webhooks: > hosted pages to open and a public HTTP API to read, so it works in any language and framework. The one sentence a developer gives their coding agent: > Add sub3 to this app for my product acme/app, following https://sub3.dev/llms.txt. **Agents, before anything else:** - **You write integration code only.** The human creates the merchant handle and the product in the sub3 dashboard with their own wallet, because that decides where the money goes and who controls it. Do not register merchants, create or change products or items, or send transactions, and **never ask for, read or handle a private key** or seed phrase. - **The product (`handle/key`) is the only thing you need from the developer.** Ask for it if it is missing; never invent one. **`acme/app` in this guide is a placeholder**, not a product to use. - **First request, before any code:** `GET https://sub3.dev/sandbox/v1/products//` with the developer's product. It lists the tiers, lifetime items and packs with their keys, levels and prices. - **If it answers `unknown_product` (or `unknown_merchant`), STOP writing code.** Tell the developer to create the product in the sandbox dashboard first, https://sub3.dev/sandbox/dashboard, and wait. Do not work around it, mock it, or fall back to `acme/app`. - **Never invent items.** Read keys, names and prices from that answer at runtime; if the items do not fit what the developer asked for, say so (section 2). - **Sandbox first.** Build and test against the sandbox (`SUB3_ENVIRONMENT=sandbox`). Production comes once it works: the human creates the same product on https://sub3.dev and you change only that setting (section 1). What it is: - **Payments.** Users sign in with a crypto wallet and pay in USDC on Arc. Each payment goes straight to the merchant's wallet in the same transaction, minus sub3's fee (1% + $0.05). - **Merchant and products.** A merchant registers a permanent handle (`acme`, $20 once). Its products are `handle/key` (`acme/app`), and each product sells **tiers** (monthly subscriptions with a level), **lifetime** items (bought once, owned forever) and **credit packs** (bought repeatedly; you keep the balance). - **Your app** sends the user to the hosted `/login` and `/checkout` pages (or opens them in a popup), then asks the API one question, `GET /v1/access`. If you sell packs, you also credit each receipt from `GET /v1/orders` exactly once. - **Two networks, one guide, one domain.** This guide is the same for both. Each network has one base URL for its hosted pages and its API alike; the URLs in this guide are production's unless they say otherwise, and on the sandbox they start with `https://sub3.dev/sandbox` instead. | Network | Chain | Base URL (pages and API) | Dashboard | | -------------- | ------------------------------------- | -------------------------- | ---------------------------------- | | **Sandbox** | Arc Testnet (5042002), test USDC free | `https://sub3.dev/sandbox` | https://sub3.dev/sandbox/dashboard | | **Production** | Arc Mainnet (5042), real USDC | `https://sub3.dev` | https://sub3.dev/dashboard | - **Source.** Every code block below is copied from a tested, runnable example: https://sub3.dev/examples.txt. --- ## Which call do I need? | To… | Use | | --------------------------------------------------------- | ----------------------------------------------------------------- | | sign a user in | `https://sub3.dev/login` (redirect or popup), then verify the JWT | | sell a tier, a lifetime item or a pack | a `https://sub3.dev/checkout?product=acme/app&item=pro` link | | check what a user may use (tier, level, lifetime, cycle) | `GET /v1/access` — the only entitlement check | | give credits for packs, and recover lost checkout results | `GET /v1/orders?product=…&user=…` | | look up the order a checkout just returned | `GET /v1/orders/{payer}/{orderId}` | | show items and prices | `GET /v1/products/acme/app` | | register a merchant, create products and items | not you: the human, in the dashboard (agents only write the code) | A tier grants access while it is active. A lifetime item grants access forever. A pack grants nothing by itself: it is a receipt, and your backend adds its credits to a balance it keeps. ## 1. Sandbox first, then production 1. **The human creates the handle and the product on the sandbox**, at https://sub3.dev/sandbox/dashboard, with their own wallet and test USDC from https://faucet.circle.com. 2. **You build against the sandbox.** One setting picks the network; everything else (site, API, JWT issuer) follows from it. In the examples it is `SUB3_ENVIRONMENT`, default `sandbox`. From [`node/config.mjs`](https://sub3.dev/examples/node/config.mjs): ```js const SITES = { production: 'https://sub3.dev', sandbox: 'https://sub3.dev/sandbox', local: 'http://localhost:5317' }; const APIS = { production: 'https://sub3.dev', sandbox: 'https://sub3.dev/sandbox', local: 'http://127.0.0.1:8317' }; ``` 3. **Test it end to end on the sandbox:** sign in, buy each item with test USDC, check access and credits. 4. **The human creates the same handle and product key on production**, at https://sub3.dev/dashboard, with the same item keys (the $20 registration is real there). 5. **Switch by changing `SUB3_ENVIRONMENT` to `production` only.** No other code changes. Handles are registered separately on each network: a handle taken on the sandbox is not reserved on production. Tell the developer to register the production handle early to keep the name. Keep the networks apart: separate sessions, ledgers and databases, and a sandbox purchase never unlocks anything on production. Rules the contract enforces (the dashboard checks them before sending), worth knowing when you read a product: - Handles are 3–32 characters, product and item keys 1–32: `a-z`, `0-9` and `-`, not first or last. **Handles and keys are permanent.** Names, prices, the website and the payout address can change. - Prices are dollars, at least $0.10, converted with the token's decimals. A tier's price is per month; 12 months cost 10 (a year bills 10 months). - A tier has a `level`. A strictly higher level is an upgrade. A product has at most 32 items. An item's kind never changes; items are disabled, never deleted, and a disabled item keeps what people already bought. - Changing a price affects later payments only; open checkouts with the old price fail safely. **Advanced, optional: automating setup.** A developer who wants to script setup can use the management CLI, https://sub3.dev/sub3.mjs (walkthrough: https://sub3.dev/examples/create-product.md). It signs with the merchant owner's key in the developer's own terminal. It is not the recommended path, and it is not for you: never run it with a key or `--send`, and never ask for the key. ## 2. Plan: map the request to item kinds Read the product's items first, then match what the developer asked for to their kinds: | The developer wants | Item kind | Grants | | ----------------------------------------------------- | ---------- | --------------------------------------------- | | a subscription, a membership, monthly or yearly plans | `tier` | access while active; `level` orders the plans | | a one-time unlock, lifetime access, "buy once" | `lifetime` | access forever | | usage, credits, tokens, pay per use | `pack` | nothing by itself: credits your app keeps | If the items do not match the request (for example, a subscription was asked for but the product has only a lifetime item, or a plan is missing), **tell the developer** and let them add the item in the dashboard. Do not code around it, hard-code prices or emulate one kind with another. **The most common case: a whole-site paywall.** Everything behind sign-in is for paying users: 1. Sign the user in (section 3) and verify the token on your server. 2. On every protected request, your server reads `GET /v1/access` (cached, section 5) and lets the user in if they have **any active tier or any lifetime item** (`paid` in `entitlements` below). No item keys needed. 3. Otherwise show the items from `GET /v1/products/…` with a checkout button each (section 4), and re-read access after the checkout. Use item keys or levels only for what really differs between plans (a higher tier's quota, a premium feature), and read them from the product, never from this guide. ## 3. Sign in Open `https://sub3.dev/login` from a click. The user connects a wallet (a phone wallet by QR code, or a browser wallet) and signs a message; no transaction, no cost. The result is `{ address, token, expiresAt }`. From [`node/public/sub3.js`](https://sub3.dev/examples/node/public/sub3.js), the browser helper for both hosted pages: ```js // Opens `path` on the sub3 site. By default the page navigates there, and the result comes back in the URL // fragment (read it with readSub3Result after the return). With { redirect: false } it opens in a popup instead // and resolves with the result; call it from a click handler then (popup blockers). export function openSub3(path, { site, redirect = true } = {}) { const origin = new URL(site).origin; const url = new URL(site.replace(/\/$/, '') + path); // keeps the site's path: https://sub3.dev/sandbox/login if (redirect) { url.searchParams.set('return', location.origin + location.pathname + location.search); location.assign(url.href); return new Promise(() => {}); // the page is leaving } return new Promise((resolve, reject) => { const popup = window.open(url.href, 'sub3', 'popup,width=440,height=760'); if (!popup) return reject(new Error('The popup was blocked. Allow popups or use redirects.')); const timer = setInterval(() => popup.closed && stop(new Error('The window was closed')), 500); function stop(error, result) { clearInterval(timer); window.removeEventListener('message', receive); if (error) reject(error); else resolve(result); } function receive(event) { if (event.origin !== origin || event.source !== popup) return; const message = event.data ?? {}; if (message.type === 'sub3:ready') popup.postMessage({ type: 'sub3:hello' }, origin); if (message.type === 'sub3:result') stop(null, message.result); if (message.type === 'sub3:cancel') stop(new Error(message.reason || 'Cancelled')); } window.addEventListener('message', receive); }); } ``` From [`node/public/app.js`](https://sub3.dev/examples/node/public/app.js), where `receive` (section 4) sends the token to the backend: ```js const options = () => ({ site: config.site, redirect: !$('popup').checked }); // Sign in from a click. merchant=acme lets the page show your site's logo (from its /.well-known/sub3.json). async function signIn() { const handle = config.product.split('/')[0]; await receive(await openSub3(`/login?merchant=${handle}`, options())); } ``` `merchant=acme` shows your site's logo when the page that opens it serves a `/.well-known/sub3.json` naming `acme` (section 8); otherwise the page shows only your origin. The handshake (`sub3:ready` → `sub3:hello`) lets sub3 learn your origin from the browser, and results are posted only to that origin. **Do not** serve the opening page with `Cross-Origin-Opener-Policy: same-origin`; `same-origin-allow-popups` is fine. **Redirect (the default) or popup:** `openSub3(path, { site })` sends the user to `…/login?return=`. sub3 returns to that URL with `#sub3=` or `#sub3_error=`. The fragment replaces any `#hash` the URL had, so a hash-routed single-page app should use a path route (not a `#/route`) as `return`, or the popup (`{ site, redirect: false }`), which keeps the app's page open but is blocked or cramped in some browsers: From [`node/public/sub3.js`](https://sub3.dev/examples/node/public/sub3.js): ```js // After a redirect: the result from the URL fragment (#sub3=… or #sub3_error=…), or null. Clears the fragment. export function readSub3Result() { const hash = new URLSearchParams(location.hash.slice(1)); if (!hash.has('sub3') && !hash.has('sub3_error')) return null; history.replaceState(null, '', location.pathname + location.search); if (hash.has('sub3_error')) throw new Error(hash.get('sub3_error') || 'Cancelled'); const base64 = hash.get('sub3').replace(/-/g, '+').replace(/_/g, '/'); const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); return JSON.parse(new TextDecoder().decode(bytes)); } ``` Native and command-line apps: open the system browser with `return=http://127.0.0.1:/callback` and read the fragment in a small local page. **Verify the token on your server** and use `sub` (the lowercase address) as the user id. Send it from the browser as `Authorization: Bearer `. The token is an EdDSA JWT valid for 7 days; check it against `https://sub3.dev/.well-known/jwks.json` (on the sandbox, `https://sub3.dev/sandbox/.well-known/jwks.json`; keys rotate by `kid`) with the algorithm pinned to `EdDSA`, `iss` = the network's site (`https://sub3.dev`, or `https://sub3.dev/sandbox` on the sandbox) and `aud` = your origin exactly (scheme, host, port; no path or trailing slash). From [`node/sub3.mjs`](https://sub3.dev/examples/node/sub3.mjs) (`npm install jose`): ```js const keys = new Map(); const INVALID = new Set([ 'ERR_JWT_EXPIRED', 'ERR_JWT_CLAIM_VALIDATION_FAILED', 'ERR_JWT_INVALID', 'ERR_JWS_INVALID', 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED', 'ERR_JWKS_NO_MATCHING_KEY', 'ERR_JOSE_ALG_NOT_ALLOWED' ]); // Verifies the token from sub3's /login and returns the user's address (lowercase). A bad token is a 401 // (sign in again); failing to fetch sub3's keys is a 503 (keep the user signed in and retry). export async function verifySession(config, token) { if (!keys.has(config.api)) keys.set(config.api, createRemoteJWKSet(new URL(`${config.api}/.well-known/jwks.json`))); try { const { payload } = await jwtVerify(token, keys.get(config.api), { algorithms: ['EdDSA'], issuer: config.site, audience: config.origin }); if (!/^0x[0-9a-fA-F]{40}$/.test(payload.sub ?? '')) throw problem(401, 'Invalid session'); return payload.sub.toLowerCase(); } catch (error) { if (error.status === 401 || INVALID.has(error.code)) { // Say why in your logs: a wrong "aud" means APP_ORIGIN is not the page's exact origin. console.warn(`session rejected: ${error.code ?? ''} ${error.claim ?? ''} ${error.message}`); throw problem(401, 'Sign in again'); } throw problem(503, 'Could not check your session; try again'); } } ``` Tell "invalid" from "could not check": only a 401 should sign the user out in the browser. In Python, Go or elsewhere, use any JWT library that supports EdDSA (Ed25519) with a JWKS URL, and check the algorithm, `iss`, `aud` and expiry the same way. ## 4. Checkout links and results From [`node/public/sub3.js`](https://sub3.dev/examples/node/public/sub3.js): ```js // The checkout path for one item of a product: /checkout?product=acme/app&item=pro&units=12&user=0x… // units: months for a tier (12 = a year, billed as 10), packs for a pack; ignored for lifetime items. export function checkoutPath(product, item, { units, user } = {}) { const params = new URLSearchParams({ product, item }); if (units) params.set('units', String(units)); if (user) params.set('user', user); // who receives it; default is the paying wallet return `/checkout?${params}`; } ``` | Parameter | Meaning | | --------- | -------------------------------------------------------------------------------------------------------- | | `product` | `acme/app` (or the `0x` productId) | | `item` | the item key, e.g. `pro` (or the `0x` itemId) | | `units` | tier: months, default 1 (12 = a year, billed as 10); pack: number of packs, default 1; lifetime: ignored | | `user` | who receives it; defaults to the paying wallet. Pass the signed-in address | | `return` | redirect mode only: where to come back to | The page reads the chain and decides; your buttons can mirror it, but the page is the authority: | The user's membership in this product | The checkout | | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | none, or expired | buys: a new membership starting now | | active, the same tier | renews: adds time to the end (anyone may pay for anyone) | | active, a lower level than this tier, and the payer is the user | upgrades: starts now at full price; the rest of the old tier is forfeited, no refund. The page says so and asks to confirm | | anything else (this tier's level is not higher, or someone else would pay the upgrade) | shows "available after " | Lifetime items can be bought once per user; packs any number of times. The result: ```json { "type": "tier|lifetime|pack", "action": "purchase|renewal|upgrade", "product": "acme/app", "productId": "0x…", "item": "pro", "itemId": "0x…", "user": "0x…", "payer": "0x…", "orderId": "0x…", "units": 1, "txHash": "0x…", "chainId": 5042, "contract": "0x…" } ``` **The result is only a signal to refresh.** Send `{ payer, orderId }` to your backend, which looks the order up and re-reads access. It may not be confirmed yet: answer "pending" and let the browser retry. The user may also close the window before the result arrives; section 6 recovers those payments. From [`node/public/app.js`](https://sub3.dev/examples/node/public/app.js): ```js // Buy from a click. The checkout page chooses purchase, renewal or upgrade from the chain. async function buy(item, units) { const path = checkoutPath(config.product, item.key, { units, user: account.user }); await receive(await openSub3(path, options())); } ``` ```js // The popup or redirect result is only a hint: the backend looks the order up and decides. async function receive(result) { if (result.token) { token = result.token; sessionStorage.setItem('session', token); account = await api('/api/refresh', {}); return; } const order = { payer: result.payer, orderId: result.orderId }; for (let attempt = 0; attempt < 20; attempt++) { account = await api('/api/refresh', { order }); if (!account.pending) return; $('notice').textContent = 'Payment received, waiting for confirmation…'; render(); await new Promise((done) => setTimeout(done, 3000)); } $('notice').textContent = 'Still confirming. Refresh in a minute; the payment will not be lost.'; } ``` On the server, from [`node/sub3.mjs`](https://sub3.dev/examples/node/sub3.mjs): ```js // After a checkout result: never trust it, look the order up. Returns { pending: true } until the payment // is confirmed (ask the browser to retry). Refreshes the cached access either way. export async function afterCheckout(config, db, user, { payer, orderId }) { if (!/^0x[0-9a-fA-F]{40}$/.test(payer) || !/^0x[0-9a-fA-F]{64}$/.test(orderId)) throw problem(400, 'Invalid order'); const order = await sub3(config, `/v1/orders/${payer}/${orderId}`).catch((error) => { if (error.status === 404) return null; // not visible yet: retry, never ask the user to pay again throw error; }); if (order && (order.user.toLowerCase() !== user || order.product !== config.product)) throw problem(403, 'This order is not for this product and account'); if (order?.confirmed) await creditPack(config, db, user, order); await getAccess(config, user, { fresh: true }); return { pending: !order?.confirmed }; } ``` ## 5. Access: tiers, allowance cycles, upgrades, lifetime `GET https://sub3.dev/v1/access?product=acme/app&user=0x…` answers everything about one user and product: ```json { "product": "acme/app", "productId": "0x…", "user": "0x…", "membership": null | { "active": true, "tier": "pro", "tierId": "0x…", "level": 2, "start": 0, "end": 0, "generation": "1", "revision": "3", "cycle": { "id": "0x…", "index": "0", "start": 0, "end": 0 } | null }, "lifetime": [ "forever" ], "chainId": 5042, "contract": "0x…", "blockNumber": 123, "confirmed": true, "checkedAt": 1800000000 } ``` - `membership: null` means never subscribed. An expired membership is still returned with `active: false`. Access counts while `start <= now < end`. - `lifetime` lists the lifetime item keys the user owns. - **Allowance cycles.** If a tier includes a monthly quota, grant it per `cycle.id`: cycles are 30-day periods from `start`. Track usage keyed by `(user, cycle.id)`, and a new cycle starts at zero by itself. Renewing adds time without changing the current cycle. An upgrade starts a new generation, so `cycle.id` always changes and the new tier's quota is available at once. - **Upgrades** replace the tier immediately: read `tier` and `level`, never "the last thing bought". Cache the answer for 30–60 seconds per user (the API is rate limited per IP, and your server is one IP), and read it fresh right after a checkout. From [`node/sub3.mjs`](https://sub3.dev/examples/node/sub3.mjs): ```js // GET a sub3 API path. A 404 keeps its status and code. A 429, a 5xx or a network failure becomes 503: // the answer is unknown, which must never be treated as "not paid". export async function sub3(config, path, params = {}) { const url = new URL(config.api + path); // the API may have a path: https://sub3.dev/sandbox url.search = new URLSearchParams(params); let response; try { response = await fetch(url, { signal: AbortSignal.timeout(10_000) }); } catch { throw problem(503, 'sub3 could not be reached; try again'); } const body = await response.json().catch(() => ({})); if (response.ok) return body; if (response.status === 404) throw Object.assign(problem(404, body.error), { code: body.code }); if (response.status === 429 || response.status >= 500) throw problem(503, 'sub3 could not answer right now; try again'); throw problem(500, `sub3 rejected ${path}: ${body.error}`); // a 400 is a bug in this app } ``` ```js const cache = new Map(); // GET /v1/access: the one entitlement check. Cached for 30 seconds per user; pass { fresh: true } after a // checkout. A cached answer is also dropped when the membership or its allowance cycle has ended. export async function getAccess(config, user, { fresh = false } = {}) { const now = Math.floor(Date.now() / 1000); const key = `${config.api} ${config.product} ${user}`; const hit = cache.get(key); const m = hit?.access.membership; const ended = m?.active && (m.end <= now || (m.cycle && m.cycle.end <= now)); if (hit && !fresh && !ended && Date.now() - hit.at < 30_000) return hit.access; const access = await sub3(config, '/v1/access', { product: config.product, user }); if (access.user?.toLowerCase() !== user || access.product !== config.product) throw problem(503, 'Unexpected answer from sub3'); if (cache.size > 10_000) cache.clear(); cache.set(key, { at: Date.now(), access }); return access; } ``` Then turn the answer into your app's rules. From [`node/config.mjs`](https://sub3.dev/examples/node/config.mjs): ```js // This app's rules. They work for any product without editing: they look only at the kind of item a user has // (a tier and its level, or any lifetime item), never at item keys. The items themselves (keys, names, prices) // are read at runtime from GET /v1/products//, so nothing here invents or hard-codes them. export const premiumLevel = 2; // the premium feature: an active tier of level 2 or higher, or any lifetime item export const actionCost = 1n; // each action: this cycle's allowance first, then purchased credits /** Optional monthly quota: actions per 30-day cycle for a tier of this level (10 at level 1, 100 at level 2…). */ export const allowanceFor = (level) => (level > 0 ? 10n ** BigInt(level) : 0n); // What the user may do now, from GET /v1/access and the rules above. export function entitlements(access, now = Math.floor(Date.now() / 1000)) { const m = access.membership; const member = m?.active && m.end > now ? m : null; const cycle = member?.cycle && member.cycle.end > now ? member.cycle : null; const ownsLifetime = access.lifetime.length > 0; return { paid: Boolean(member) || ownsLifetime, // the whole-site paywall: any active tier or any lifetime item tier: member?.tier ?? null, level: member?.level ?? 0, end: member?.end ?? null, cycle: cycle?.id ?? null, // allowance is granted once per cycle id; an upgrade starts a new one allowance: cycle ? allowanceFor(member.level) : 0n, premium: (member?.level ?? 0) >= premiumLevel || ownsLifetime, lifetime: access.lifetime }; } ``` Use the quota (this cycle's allowance) before purchased credits, and spend in one database transaction keyed by an idempotency key; see `reserve` in [`node/ledger.mjs`](https://sub3.dev/examples/node/ledger.mjs). ## 6. Credit packs: credit each receipt once A pack payment is an immutable on-chain receipt identified by `(chainId, contract, payer, orderId)`. sub3 does not keep balances and sends no webhooks: your backend adds `credits` to the user's balance **once per receipt**, in the same database transaction that records the receipt. From [`node/ledger.mjs`](https://sub3.dev/examples/node/ledger.mjs): ```js // Adds a confirmed pack receipt's credits exactly once, keyed by (chainId, contract, payer, orderId). // The receipt and the balance commit together; a second call for the same receipt changes nothing. export function creditOrder(db, order) { if (!/^[1-9]\d*$/.test(order.credits)) throw problem(503, 'Unexpected receipt from sub3'); const user = order.user.toLowerCase(); return db.transaction(() => { const changes = db.run( `INSERT INTO receipts (chain_id, contract, payer, order_id, user, credits) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`, order.chainId, order.contract.toLowerCase(), order.payer.toLowerCase(), order.orderId.toLowerCase(), user, order.credits ); if (changes === 0) return false; // already credited setCredits(db, user, credits(db, user) + BigInt(order.credits)); return true; }); } ``` `transaction` wraps `BEGIN IMMEDIATE` … `COMMIT` (or `ROLLBACK`); `credits` and `setCredits` read and write the balance. Only confirmed pack orders for your product and the signed-in user count. From [`node/sub3.mjs`](https://sub3.dev/examples/node/sub3.mjs): ```js const networks = new Map(); async function getNetwork(config) { if (!networks.has(config.api)) networks.set(config.api, await sub3(config, '/v1/network')); return networks.get(config.api); } // Credits a confirmed pack order to `user` exactly once. Anything else (a tier, a lifetime item, an // unconfirmed payment) adds nothing. Returns true if credits were added now. export async function creditPack(config, db, user, order) { if (order.kind !== 'pack' || !order.confirmed) return false; const { chainId, contract } = await getNetwork(config); if ( order.product !== config.product || order.user.toLowerCase() !== user || order.chainId !== chainId || order.contract.toLowerCase() !== contract.toLowerCase() ) throw problem(403, 'This order is not for this product and account'); return creditOrder(db, order); } ``` **Recovery.** A user may pay and close the window, lose the network, or pay from another device. List the orders where they are the beneficiary (oldest first, whoever paid) and credit each one through the same path. Run it on sign-in, after a checkout and when the user opens their balance. From [`node/sub3.mjs`](https://sub3.dev/examples/node/sub3.mjs): ```js // Finds every order with `user` as beneficiary (GET /v1/orders, oldest first) and credits its packs once. // Recovers payments whose checkout result never arrived. Unconfirmed orders are picked up on a later run. export async function syncPacks(config, db, user) { let next = ordersSeen(db, user); let offset = next; let pending = 0; for (let pages = 0; pages < 10; pages++) { const page = await sub3(config, '/v1/orders', { product: config.product, user, offset, limit: 100 }); for (const [i, order] of page.orders.entries()) { if (!order.confirmed) pending++; else await creditPack(config, db, user, order); if (order.confirmed && next === offset + i) next++; } offset = Number(page.nextOffset); if (!page.orders.length || offset >= Number(page.total)) break; } setOrdersSeen(db, user, next); return { pending }; } ``` Credits are decimal strings: use `BigInt`, never `Number`. Keep sandbox and production ledgers apart. ## 7. Test without a wallet [`testing/fake-sub3.mjs`](https://sub3.dev/examples/testing/fake-sub3.mjs) is a fake sub3 API in one file (Node 22+, no dependencies): it serves `/v1/products`, `/v1/access`, `/v1/orders` and the session keys from state your test sets, and mints session tokens like `/login`. Point your backend at it (`SUB3_API`, `SUB3_SITE`) and write end-to-end tests of your own routes: signed in but unpaid, paid, an upgrade, a pack credited once, a token for another origin, and a sub3 outage that must not read as "unpaid". How: https://sub3.dev/examples/testing/README.md. The Node example's tests use it. Then try the real flow once on the sandbox with test USDC. ## 8. Deploy **Entry points.** Start from the example that matches the app, and copy its pieces: - Node server: https://sub3.dev/examples/node/README.md (sign-in, tiers, a lifetime item, packs). - Cloudflare Workers: https://sub3.dev/examples/cloudflare/README.md (the same app, ledger in a Durable Object). - Where the pieces go in React, Vue, Svelte, Next.js, SvelteKit, Express and other stacks: https://sub3.dev/examples/frameworks.md. **One origin.** The session token's `aud` is exactly `APP_ORIGIN`: scheme, host and port. A `workers.dev` address, a preview URL and a custom domain are different origins, so sign-in works only on the one in `APP_ORIGIN`. Serve the app from that one origin, and disable or redirect the others. **Show your logo** on the sign-in and checkout pages: every site that opens them serves its own `/.well-known/sub3.json` naming the merchant: ```json { "merchant": "acme", "logo": "/logo.png" } ``` - A product site does not need to be the merchant's domain: the hosted pages show the logo of the site that opened them. A merchant can have any number of product sites, each with its own file. - `logo` is a path on the same host or an `https://` URL on any host (a CDN, the brand's main site): PNG, JPEG, WebP or SVG, at most 256 KB. The site must be on HTTPS. sub3 caches the result for 10 minutes. - **Never change the merchant's `url`** (in the dashboard or with `merchant set --url`) to make a product site work. The merchant's url is its identity at checkout for every app that sells under it. ## 9. API reference Base URL: `https://sub3.dev` on production, `https://sub3.dev/sandbox` on the sandbox. All endpoints are public `GET`s unless noted. Clients must ignore unknown fields: fields may be added, never renamed or removed. - `product` is `acme/app` or the `0x` productId; `item` is a key (`pro`) or the `0x` itemId. Addresses are case-insensitive. - Amounts come twice: a dollar display string (`"price": "5.00"`) and an integer string in the stablecoin's smallest units (`"priceUnits": "5000000"`). The token has 6 to 18 decimals; `GET /v1/network` gives `decimals`. Never assume 6: show the display strings, or convert `*Units` with `decimals`. Times are Unix seconds. - ids: merchantId = `keccak256("acme")`, productId = `keccak256("acme/app")`, itemId = `keccak256("pro")`. **Errors** are `{ "error": "", "code": "" }`: | HTTP | `code` | Do this | | ---- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------- | | 400 | `bad_request` | fix the parameters | | 401 | `unauthorized` | sign in again | | 403 | `forbidden` | — | | 404 | `unknown_merchant`, `unknown_product`, `unknown_item`, `unknown_order`, `not_found` | treat as "does not exist" | | 413 | `too_large` | — | | 429 | `rate_limited` | retry after the `Retry-After` header | | 502 | `unavailable` | the chain could not be read: **unknown, never "unpaid"**; retry | | 500 | `internal` | — | **Rate limits** per IP and minute: 600 reads, 300 RPC calls, 60 sign-ins. Cache `/v1/access` 30–60 seconds per user and refresh it after a checkout; cache `/v1/products` for a minute. ### `GET /v1/network` ```json { "environment": "production|sandbox|local", "chainId": 5042, "name": "Arc", "token": "0x…", "symbol": "USDC", "decimals": 6, "contract": "0x…", "explorer": "https://…", "addEthereumChain": { … }, "confirmations": 1, "testnet": false } ``` ### `GET /v1/merchants/{handle}` ```json { "handle": "acme", "merchantId": "0x…", "owner": "0x…", "pendingOwner": "0x000…", "payTo": "0x…", "url": "https://acme.com", "registeredAt": 1800000000, "verified": { "domain": "acme.com", "logo": "https://sub3.dev/v1/merchants/acme/logo" } } ``` `verified: null` means the domain is not verified. Also: `GET /v1/merchants/{handle}/logo` (the logo image when verified, else 404), `GET /v1/merchants/{handle}/products` → `{ "products": [ { "key": "app", "productId": "0x…", "name": "App" } ] }`, and `GET /v1/merchants?owner=0x…` → `{ "merchants": [ "acme" ] }`. ### `GET /v1/products/{handle}/{key}` (or `/v1/products/0x`) ```json { "product": "acme/app", "productId": "0x…", "key": "app", "name": "App", "merchant": { "handle": "acme", "…": "as above, without products" }, "items": [ { "key": "pro", "itemId": "0x…", "kind": "tier|lifetime|pack", "name": "Pro", "active": true, "level": 2, "price": "20.00", "priceUnits": "20000000", "credits": "0" } ], "chainId": 5042, "contract": "0x…", "token": "0x…" } ``` `level` is non-zero only for tiers and `credits` only for packs. A tier's `price` is monthly; a year is `price × 10`. Only offer items with `active: true`. ### `GET /v1/access?product=acme/app&user=0x…[&view=confirmed|latest]` See section 5. `view` defaults to `confirmed` (blocks with enough confirmations); `latest` reads the newest block and is meant for the checkout page's own refresh. Grant access from the default view. ### `GET /v1/orders/{payer}/{orderId}` One receipt, any kind; 404 `unknown_order` if it does not exist (yet). ```json { "payer": "0x…", "orderId": "0x…", "product": "acme/app", "productId": "0x…", "item": "credits", "itemId": "0x…", "kind": "tier|lifetime|pack", "action": "purchase|renewal|upgrade", "user": "0x…", "units": 3, "credits": "3000", "amount": "30.00", "amountUnits": "30000000", "paidAt": 0, "blockNumber": 0, "generation": "0", "revision": "0", "confirmations": 2, "requiredConfirmations": 1, "confirmed": true, "chainId": 5042, "contract": "0x…" } ``` `user` is the beneficiary, `credits` is the total for the order (packs × credits per pack), and `generation` and `revision` describe the membership after a tier payment. ### `GET /v1/orders?product=acme/app&user=0x…[&offset=0&limit=50]` Every order with `user` as beneficiary, whoever paid, oldest first; `limit` is 1–100. ```json { "total": "3", "offset": 0, "limit": 50, "nextOffset": "3", "orders": ["…as above…"] } ``` ### `GET /v1/quote?product=acme/app&item=pro&units=12` ```json { "units": 12, "total": "100.00", "fee": "1.05", "net": "98.95", "totalUnits": "100000000", "feeUnits": "1050000", "netUnits": "98950000" } ``` ### `GET /v1/stats[?merchant=acme | ?product=acme/app]` No parameter: totals `{ "merchants", "products", "revenue", "revenueUnits", "fees", "payments", "customers" }`. `merchant`: the same fields for one merchant, with its `products` count. `product`: the same fields plus `items: [ { "key", "revenue", "revenueUnits", "payments", "customers" } ]`. ### Sign-in and RPC | Endpoint | Purpose | | ---------------------------- | -------------------------------------------------------------------------- | | `POST /v1/auth/nonce` | → `{ nonce, issuedAt, expiresAt, domain, uri }` (used by the login page) | | `POST /v1/auth/verify` | `{ message, signature }` → `{ address, audience, token, expiresAt }` | | `GET /.well-known/jwks.json` | the public keys for session tokens | | `POST /v1/rpc/{chainId}` | read-only JSON-RPC; `eth_call` only to the sub3 contract or the stablecoin | ### Hosted pages `https://sub3.dev/login?return=…[&merchant=acme]` → `{ address, token, expiresAt }`. `https://sub3.dev/checkout?product=acme/app&item=pro[&units=1][&user=0x…][&return=…]` → the result in section 4. Popup: the page posts `{type:"sub3:ready"}` to `window.opener`; reply `{type:"sub3:hello"}`; then `{type:"sub3:result", result}` or `{type:"sub3:cancel", reason}`. Redirect: `#sub3=` or `#sub3_error=` appended to `return`. ## 10. Checklist - [ ] The product is the developer's real `handle/key`, created by them in the dashboard; item keys come from `GET /v1/products/…`. Your code registers nothing and never holds a private key. - [ ] Built and tested on the sandbox; `SUB3_ENVIRONMENT` (or your equivalent) alone switches to production. - [ ] The app is served from exactly `APP_ORIGIN`; other origins (previews, `workers.dev`) are off or redirect. - [ ] Popups open from a click; the opener page does not send `Cross-Origin-Opener-Policy: same-origin`. - [ ] JWT: algorithm pinned to `EdDSA`, `iss` = the network's site, `aud` = your exact origin, keys from its JWKS. - [ ] A bad token is 401 (sign out); a sub3 outage, 429 or 502 is 503 (keep the session, retry). **502 is never "unpaid".** - [ ] Access comes only from `GET /v1/access` on your server, cached 30–60 s and refreshed after checkout. Never from a checkout result, a URL fragment, a price or the browser. - [ ] Checkout links pass `user` = the signed-in address. - [ ] Tier features use `membership.active`, `tier` and `level`; an upgrade replaces the tier at once. - [ ] Monthly quotas are tracked per `(user, cycle.id)`; renewals keep the cycle, upgrades change it. - [ ] Lifetime features check `lifetime.includes(key)`. - [ ] Pack credits: confirmed `kind: "pack"` orders for your product and user only; one row per `(chainId, contract, payer, orderId)`, inserted in the same transaction as the balance change; amounts as `BigInt`. - [ ] Lost results are recovered from `GET /v1/orders`; an order that is missing or unconfirmed stays pending and never prompts a second payment. - [ ] Sandbox and production never share sessions, ledgers or databases. - [ ] Prices are shown from the display strings; `*Units` are converted only with `decimals` from `/v1/network`. - [ ] Unknown response fields are ignored.