# sub3: wallet sign-in and stablecoin payments for any app People: the step-by-step guide is at https://sandbox.sub3.dev/docs, and the frozen specification at https://sandbox.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. - **What it is.** 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** opens `https://sandbox.sub3.dev/login` and `https://sandbox.sub3.dev/checkout` in a popup (or redirects to them), then asks the API one question, `GET /v1/access`. If you sell packs, you also credit each receipt from `GET /v1/orders` exactly once. - **Networks.** Production is Arc Mainnet (5042): `https://sandbox.sub3.dev` and `https://api-sandbox.sub3.dev`. The sandbox is Arc Testnet (5042002) with free test USDC; it has its own site, API, contract and sessions. - **Source.** Every code block below is copied from a tested, runnable example: https://sandbox.sub3.dev/examples.txt. The one sentence a developer gives their coding agent: > Add sub3 to this app for my product acme/app, following https://sandbox.sub3.dev/llms.txt. Agents: the product (`handle/key`) is the only thing you need from the developer. Ask for it if it is missing; never invent one. Read what it sells from `GET https://api-sandbox.sub3.dev/v1/products/acme/app`. > **Sandbox — Arc Testnet (5042002).** Payments use test USDC (free from > https://faucet.circle.com). Sandbox sessions, memberships and credits must never grant production access or > balances. For real payments on Arc Mainnet (5042), use https://sub3.dev/llms.txt. --- ## Which call do I need? | To… | Use | | ---------------------------------------------------------- | ----------------------------------------------------------------- | | sign a user in | `https://sandbox.sub3.dev/login` (popup or redirect), then verify the JWT | | sell a tier, a lifetime item or a pack | a `https://sandbox.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, read stats | the CLI (`https://sandbox.sub3.dev/sub3.mjs`) or the dashboard | 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. Setup: merchant, product, domain Do this once, with the CLI or at https://sandbox.sub3.dev/dashboard. The CLI is one file that needs Node 22+ and `viem`: ```sh curl -fsSO https://sandbox.sub3.dev/sub3.mjs && npm install viem export SUB3_PRIVATE_KEY=0x… # the merchant owner's wallet; never put it in a prompt or a file in git node sub3.mjs merchant register acme --url https://acme.com --pay-to 0xYourPayoutAddress node sub3.mjs product create acme/app --name "Acme App" \ --tier basic:1:5:Basic --tier pro:2:20:Pro --lifetime forever:99:Forever --pack credits:5:1000:"1000 credits" ``` Every write is validated and **simulated** first, and the CLI prints what would happen. Add `--send` to broadcast; registration then approves and pays the $20 fee (the owner wallet needs $20 of USDC plus network fees, which Arc charges in USDC). Agents: show the simulation to the developer and let them run `--send`. More commands: `merchant set`, `merchant show`, `product show`, `item set`, `item disable`, `stats`, `verify`; see `node sub3.mjs help`. The CLI uses the network of the site it was downloaded from; `SUB3_ENVIRONMENT` overrides it. Rules the contract enforces (the CLI checks them before sending): - 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. **Verify your domain** so the hosted pages show your logo and `@acme ✓`. Serve this at `https://acme.com/.well-known/sub3.json` (the host of the merchant's `url`): ```json { "merchant": "acme", "logo": "/logo.png" } ``` `logo` is a path on the same host or an `https://` URL: PNG, JPEG, WebP or SVG, at most 256 KB. sub3 caches the result for 10 minutes. `node sub3.mjs verify acme` checks the same rules and says exactly what is wrong. ## 2. Sign in Open `https://sandbox.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://sandbox.sub3.dev/examples/node/public/sub3.js), the browser helper for both hosted pages: ```js // Opens `path` on the sub3 site and resolves with its result. Call it from a click handler (popup blockers). // With { redirect: true } the page navigates instead and the result comes back in the URL fragment. export function openSub3(path, { site, redirect = false } = {}) { const origin = new URL(site).origin; const url = new URL(path, origin); 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://sandbox.sub3.dev/examples/node/public/app.js), where `receive` (section 3) sends the token to the backend: ```js const options = () => ({ site: config.site, redirect: $('redirect').checked }); // Sign in from a click. merchant=acme lets the page show your verified logo and @acme. async function signIn() { const handle = config.product.split('/')[0]; await receive(await openSub3(`/login?merchant=${handle}`, options())); } ``` `merchant=acme` shows your logo and `@acme ✓` when the page opening it is on your verified domain (or a subdomain); 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 instead of a popup:** `openSub3(path, { site, redirect: true })` sends the user to `…/login?return=`. sub3 returns to that URL with `#sub3=` or `#sub3_error=`: From [`node/public/sub3.js`](https://sandbox.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://api-sandbox.sub3.dev/.well-known/jwks.json` (keys rotate by `kid`) with the algorithm pinned to `EdDSA`, `iss` = `https://sandbox.sub3.dev` and `aud` = your origin exactly (scheme, host, port; no path or trailing slash). From [`node/sub3.mjs`](https://sandbox.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('/.well-known/jwks.json', config.api))); 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. ## 3. Checkout links and results From [`node/public/sub3.js`](https://sandbox.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": 5042002, "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 5 recovers those payments. From [`node/public/app.js`](https://sandbox.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://sandbox.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 }; } ``` ## 4. Access: tiers, allowance cycles, upgrades, lifetime `GET https://api-sandbox.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": 5042002, "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://sandbox.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(path, config.api); 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://sandbox.sub3.dev/examples/node/config.mjs): ```js // What this app sells and what each item unlocks. Edit these to match the items of your product, created with // node sub3.mjs product create acme/app --name "Acme App" --tier basic:1:5:Basic --tier pro:2:20:Pro \ // --lifetime forever:99:Forever --pack credits:5:1000:"1000 credits" export const allowances = { basic: 10n, pro: 100n }; // actions per monthly cycle, by tier key export const premium = { level: 2, lifetime: 'forever' }; // the premium feature: tier level 2+, or this lifetime item export const actionCost = 1n; // each action: this cycle's allowance first, then purchased credits // 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; return { 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 ? (allowances[member.tier] ?? 0n) : 0n, premium: (member?.level ?? 0) >= premium.level || access.lifetime.includes(premium.lifetime), 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://sandbox.sub3.dev/examples/node/ledger.mjs). ## 5. 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://sandbox.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://sandbox.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://sandbox.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. ## 6. API reference Base URL: `https://api-sandbox.sub3.dev` (the sandbox has its own). 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": 5042002, "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://api-sandbox.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": 5042002, "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 4. `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": 5042002, "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://sandbox.sub3.dev/login?return=…[&merchant=acme]` → `{ address, token, expiresAt }`. `https://sandbox.sub3.dev/checkout?product=acme/app&item=pro[&units=1][&user=0x…][&return=…]` → the result in section 3. 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`. ## 7. Checklist - [ ] The product is the developer's real `handle/key`; item keys come from `GET /v1/products/…`. - [ ] Popups open from a click; the opener page does not send `Cross-Origin-Opener-Policy: same-origin`. - [ ] JWT: algorithm pinned to `EdDSA`, `iss` = `https://sandbox.sub3.dev`, `aud` = your exact origin, keys from the 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.