Add sub3 to your app

A step-by-step guide for developers. If a coding agent writes the code, do step 1 yourself and then give it the sentence the dashboard shows for your product:

Add sub3 to this app for my product acme/app, following https://sub3.dev/llms.txt.

llms.txt is this integration in one file, for agents. 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.

Sandbox first. Build and test everything on the sandbox: it runs on Arc Testnet with free test USDC from https://faucet.circle.com, and nothing there touches real money. When it works end to end, you create the same handle and product on sub3.dev and switch one setting (step 7). In the examples that setting is SUB3_ENVIRONMENT: sandbox (the default) uses https://sub3.dev/sandbox, production uses https://sub3.dev; each is the base URL of both the hosted pages and the API. The URLs in the steps below are production's; on the sandbox they start with https://sub3.dev/sandbox instead.

1. Create your handle and product in the dashboard

Do this yourself, with your own wallet, even if a coding agent writes the code: your handle and product decide where the money goes and who controls it. An agent never needs your wallet or its key.

  1. Open the sandbox dashboard and connect your wallet. Get free test USDC from https://faucet.circle.com: it pays the registration fee and the network fees, which Arc charges in USDC.
  2. Choose Register a merchant: your handle (@acme), your website and the address that receives payments.
  3. Create a product (acme/app) and add its tiers, lifetime items and credit packs.

Your handle is your permanent name on sub3; it costs $20 once and is owned by your wallet. Products live under it and their items are what users buy. The product page in the dashboard shows the sentence for your coding agent.

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.
  • Handles are registered separately on the sandbox and on production. Register yours on sub3.dev early to keep the name.
Advanced: automate setup from the command line

If you want to script setup, the optional CLI does the same from your own terminal. It is one file that needs Node 22+ and viem, and it signs with your wallet's key, so run it yourself and never give the key to an agent:

sh
curl -fsSO https://sub3.dev/sub3.mjs && npm install viem
export SUB3_ENVIRONMENT=sandbox   # then production when you go live
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.

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

json
{ "merchant": "acme", "logo": "/logo.svg" }
  • Any site of yours that opens the hosted pages and serves this file shows its own logo there. It does not need to be your merchant's website: one handle can have any number of product sites, each with its own file.
  • On your merchant's website (the URL you registered), checkout also shows @acme with a verified ✓; the dashboard's Domain verification card shows its status.
  • logo is a path on the same host, or an https:// URL on any host (a CDN, your main site): PNG, JPEG, WebP or SVG, at most 256 KB. The site must be on HTTPS, so deploy before you check. sub3 caches the result for 10 minutes.
  • Never change your merchant's website to make a product site work. It is your identity at checkout for every app you sell under the handle.

The Node example serves the file from node/app.mjs.

3. Add sign-in

Copy node/public/sub3.js into your frontend. Its openSub3 sends the user to a hosted page, which brings them back to your page with the result; readSub3Result() reads it. With redirect: false it opens the page in a popup instead and resolves with the result; call it from a click then, or the browser blocks the popup.

From node/public/app.js:

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

The user connects a wallet and signs a message; it costs nothing. The result is { address, token, expiresAt }. The user comes back to your page, and readSub3Result() reads the result from the URL when the page loads. The result arrives in the URL fragment and replaces any #hash, so an app with hash routing (#/page) should use the popup.

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://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://sub3.dev, or https://sub3.dev/sandbox on the sandbox); 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://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. For a whole-site paywall, the most common case, let in anyone with an active tier or any lifetime item (paid below); no item keys are needed. Use levels or keys only for what really differs between your plans. 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;
	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
	};
}

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: going live means doing step 1 again on production and switching one setting.

  1. Test end to end on the sandbox. Sign in, buy each item with test USDC, and check access and credits. For automated tests without a wallet, run your backend against the fake sub3 API in testing/fake-sub3.mjs (how).
  2. Create the same handle and product on production. In the production dashboard, register the same handle and create the product with the same product key and item keys. This costs the real $20.
  3. Switch your app. Set SUB3_ENVIRONMENT=production, which points it at https://sub3.dev instead of https://sub3.dev/sandbox. Nothing else in the code changes. Serve it over HTTPS with your real origin as the token audience (APP_ORIGIN). Sign-in works only on that exact origin: a preview URL, a workers.dev address and a custom domain are different origins, so serve one and turn off or redirect the others.
  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 by opening sign-in from your production site.

Next