Add sub3 to your app

A step-by-step guide for developers. If a coding agent does the work, give it llms.txt instead: it is the same integration in one file. The contract and API that both rely on are frozen in the specification.

What you'll build

Your users sign in with a crypto wallet and pay you in USDC on Arc, from a phone wallet (by scanning a QR code) or a browser wallet. Each payment is split on chain: the price minus sub3's fee (1% + $0.05) goes straight to your wallet in the same transaction, and sub3 never holds funds. Your app opens two hosted pages, /login and /checkout, and your server asks one public API what each user may use. There is no account, API key, SDK or webhook.

A product, such as acme/app, can sell three kinds of items:

Kind How it works
Tier A monthly subscription with a level. 12 months cost 10. A user has one tier per product: a higher level is an upgrade that starts at once; a lower one waits until expiry.
Lifetime Bought once, owned forever.
Credit pack Bought any number of times. Each payment is a receipt; your server adds its credits to a balance it keeps.

Try everything on the sandbox first: it runs on Arc Testnet with free test USDC from https://faucet.circle.com, and nothing there touches real money.

1. Register your handle and create a product

Your handle (@acme) is your permanent name on sub3; it costs $20 once and is owned by your wallet. Products live under it (acme/app) and their items are what users buy.

From the dashboard. Open the dashboard and connect your wallet. Choose Register a merchant: enter the handle, your website and the address that receives payments. Then create a product and add its tiers, lifetime items and packs.

From the command line. 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"

Each command only simulates and prints what it would do; add --send to broadcast. The full walkthrough is create-product.md.

Worth knowing before you choose names:

  • Handles and product and item keys are permanent. Names, prices, your website and the payout address can change.
  • Prices are in dollars, at least $0.10. A tier's price is per month.
  • A product has at most 32 items. Items are disabled, never deleted, and a disabled item keeps what people already bought.

The sign-in and checkout pages show who the user is dealing with. To put your logo there, serve this file from your website at /.well-known/sub3.json:

json
{ "merchant": "acme", "logo": "/logo.svg" }
  • On your merchant's website (the URL you registered), it shows your logo, @acme and a verified ✓.
  • On any other site of yours that opens the hosted pages, it shows that site's own logo. One handle can have any number of product sites; each serves its own file.
  • logo is a path on the same host, or an https:// URL on it: PNG, JPEG, WebP or SVG, at most 256 KB. sub3 caches the result for 10 minutes.

Check it with node sub3.mjs verify acme, which says exactly what is wrong. The site must be on HTTPS, so deploy before you check. The Node example serves the file from node/app.mjs.

3. Add sign-in

Copy node/public/sub3.js into your frontend. Its openSub3 opens a hosted page as a popup (or a redirect) and resolves with the page's result. Call it from a click, or the browser blocks the popup.

From node/public/app.js:

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()));
}

The user connects a wallet and signs a message; it costs nothing. The result is { address, token, expiresAt }. With redirect: true the user comes back to your page instead, and readSub3Result() reads the result from the URL when the page loads.

Send the token to your server as Authorization: Bearer <token> and verify it there. It is an EdDSA JWT, valid for 7 days, signed with the keys at https://api-sandbox.sub3.dev/.well-known/jwks.json. From node/sub3.mjs (npm install jose):

js
const { payload } = await jwtVerify(token, keys.get(config.api), {
	algorithms: ['EdDSA'],
	issuer: config.site,
	audience: config.origin
});
  • issuer is the sub3 site (https://sandbox.sub3.dev); audience is your page's exact origin: scheme, host and port, with no path or trailing slash.
  • The user id is sub, the wallet address in lowercase.
  • A bad token means "sign in again" (401). If sub3's keys cannot be fetched, answer 503 and keep the user signed in.
  • Do not serve the page that opens the popup with Cross-Origin-Opener-Policy: same-origin.

4. Add checkout buttons

A checkout is a link to /checkout with the product, the item and the signed-in user. From 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()));
}

units is the number of months for a tier (12 is a year, billed as 10) or of packs; lifetime items ignore it. The checkout page reads the chain and decides what the click means:

The user's membership The checkout
none, or expired starts a new membership now
active, the same tier renews it: time is added to the end
active at a lower level than this tier, and the user pays upgrades at once, at full price; the rest of the old tier is forfeited
anything else (not a higher level, or someone else pays) is available after the current membership ends

The result names the order (payer, orderId). It is only a signal to refresh: send it to your server, which looks the order up and re-reads access. On the server, from 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. Check access on your server

Before anything paid, your server asks GET https://api-sandbox.sub3.dev/v1/access?product=acme/app&user=0x…. It is the only entitlement check, and the answer holds everything about one user and product:

json
{
	"product": "acme/app",
	"user": "0x…",
	"membership": {
		"active": true,
		"tier": "pro",
		"level": 2,
		"start": 1800000000,
		"end": 1802592000,
		"cycle": { "id": "0x…", "index": "0", "start": 1800000000, "end": 1802592000 }
	},
	"lifetime": ["forever"]
}
  • Tiers. Access counts while start <= now < end. Read tier and level, never "the last thing bought": an upgrade replaces the tier at once. membership is null if the user never subscribed.
  • Allowance cycles. If a tier includes a monthly quota, grant it per cycle.id. Cycles are 30-day periods from start; a new one starts at zero by itself. A renewal keeps the current cycle; an upgrade starts a new one, so the higher quota is available at once.
  • Lifetime. lifetime lists the lifetime items the user owns.

Turn the answer into your app's rules. From node/config.mjs:

js
// 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
	};
}

Cache the answer for 30 to 60 seconds per user and read it fresh after a checkout; getAccess in node/sub3.mjs does both. If sub3 cannot answer (a timeout, a 429 or a 502), the answer is unknown: retry, and never treat it as "not paid".

6. Sell credit packs

A pack payment is a receipt on chain, identified by (chainId, contract, payer, orderId). sub3 keeps no balances: your server adds the receipt's credits to the user's balance exactly once, in the same database transaction that records the receipt. From 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;
	});
}

Users sometimes pay and close the window before the result arrives, or pay from another device. On sign-in and whenever the user opens their balance, list their orders with GET /v1/orders?product=acme/app&user=0x… and credit each confirmed pack through the same path; syncPacks in node/sub3.mjs does this. Credits are decimal strings: use BigInt, never Number.

7. Go live

The sandbox and production are separate networks with separate sites, APIs, contracts and sessions. Nothing carries over, so going live means doing step 1 again on production.

  1. Build on the sandbox. Register and create your product on the sandbox (or run the CLI with SUB3_ENVIRONMENT=sandbox), get test USDC from https://faucet.circle.com, and point your app at the sandbox site and API.
  2. Register on production. Register your handle and create the same product on sub3.dev. This costs the real $20.
  3. Switch your app. Point it at https://sub3.dev and https://api.sub3.dev (in the examples: SUB3_ENVIRONMENT=production), and serve it over HTTPS with your real origin as the token audience.
  4. Keep them apart. Never share sessions, ledgers or databases between the sandbox and production: a sandbox purchase must never unlock anything real.
  5. Check your logo with node sub3.mjs verify acme against production.

Next

  • Specification: the frozen contract, API and hosted pages that every integration relies on.
  • llms.txt: this integration in one file for your coding agent, with the full API reference.
  • Node example: a runnable app with sign-in, tiers, a lifetime item and credit packs.
  • Cloudflare example: the same app on Workers with a Durable Object ledger.
  • All example files, and where the pieces go in React, Vue, Svelte, Next.js and other frameworks.