sub3 Specification v1
| Version | v1 (contract ABI v2, HTTP API v1) |
| Status | Frozen once Arc Mainnet is live; until then may change, recorded in the changelog (§9) |
| Read it at | https://sub3.dev/spec · Markdown: https://sub3.dev/spec.md · ABI: https://sub3.dev/abi.json |
| Source | docs/INTERFACES.md in the sub3 repository |
This document is the single authoritative list of everything sub3 promises to the outside world: the on-chain contract, the HTTP API, the hosted pages and their messages, website verification, and the entry points for coding agents. The contract, API, hosted pages, examples and guides all follow it; where code disagrees with this document, the code has a bug.
#0. Stability and change policy
Every section carries one of these marks:
| Mark | Level | Applies to | Allowed changes |
|---|---|---|---|
| 🔒 | Permanent | Deployed on chain, or data formats third parties keep for a long time | None. Only a new version (new contract and salt, new /v2 path); the old version keeps working |
| 🧊 | Frozen | HTTP API, hosted page parameters, message formats, URL paths | Additive only within v1: new fields, parameters and endpoints. Nothing is renamed, removed, or changes meaning or type |
| ✏️ | Mutable | Page appearance, wording, internal implementation | Any time |
Policy:
- Frozen items are never changed or removed. Within v1, changes are additive only.
- A breaking change is a new version: a new contract salt (
sub3.checkout.v3), a new HTTP path prefix (/v2), or a new browser storage key suffix (.v2). The old version keeps being served. - When it takes effect: 🔒 and 🧊 apply from the moment the contract is deployed on Arc Mainnet (chain 5042). Until then (local and testnet only) they may change, and every change is recorded in the changelog (§9) together with its reason and the compatibility plan.
- Clients must ignore fields they do not know, so that adding a field never requires integrators to change code.
#1. On-chain contract 🔒
- Source:
contracts/src/Sub3Checkout.sol - ABI:
interfaces/abi/Sub3Checkout.v2.json, generated byforge inspect; CI checks that it matches the source. Served at https://sub3.dev/abi.json. - Address:
0x5F7c7E293F3EaDBc730EF62Cf9B6ee11f2d60dF7on Arc Mainnet (5042) and Arc Testnet (5042002), and on any chain it is deployed to later. It is also thecontractfield ofGET /v1/network(§2.1) andaddressindeployments/<chainId>.json(nullon a chain where it is not deployed yet).
#1.1 Deployment
| Item | Value |
|---|---|
| Method | CREATE2 through the universal factory 0x4e59b44847b379578588920cA78FbF26c0B4956C (Arachnid's keyless deployer) |
| Salt | keccak256("sub3.checkout.v2") |
| Constructor arguments | Only treasury |
| Address | Depends only on the salt, the bytecode and the treasury. As long as the Safe has the same address on every chain, the contract has the same address on every chain, whoever deploys it |
| Initialization | After deployment the treasury (Safe) calls initialize(token) once to set the chain's stablecoin (6 to 18 decimals; emits Initialized). Until then every purchase and registration reverts with NotInitialized |
| Administrator | None. No owner, not upgradeable, cannot be paused. initialize can be called only once; after that the treasury has no privileges and only receives fees |
| Treasury | sub3's Safe multisig, fixed at deployment; signers change inside the Safe. Before a new chain is added, the same Safe address is first deployed there with the official Safe factory and the same parameters |
| Compiler settings | contracts/foundry.toml (solc 0.8.30, via_ir, optimizer_runs 200, no metadata hash). Changing any of them changes the address |
| New version | A new contract with a new salt (e.g. sub3.checkout.v3). The old contract stays readable forever |
#1.2 ID derivation (anyone can compute IDs from the readable keys)
| ID | Formula | Example |
|---|---|---|
| merchantId | keccak256(bytes(handle)) | keccak256("acme") |
| productId | keccak256(bytes(handle + "/" + productKey)) | keccak256("acme/app") |
| itemId | keccak256(bytes(itemKey)), unique within its product | keccak256("pro") |
| orderId | Any nonzero bytes32 chosen by the payer, unique per payer | — |
| Order key | (chainId, contract, payer, orderId) | — |
The contract computes the same IDs with merchantIdOf(handle), productIdOf(handle, key) and itemIdOf(key).
Key rules: only a-z, 0-9 and -, not starting or ending with -. A handle is 3–32 characters; product and item
keys are 1–32 characters. Handles and keys never change once created.
#1.3 Economics (constants)
| Constant | Value |
|---|---|
| Fee | 1% of each payment (FEE_BPS = 100, rounded down) + feeFlat() ($0.05) |
minPrice() | $0.10, the lowest price of any item |
merchantFee() | $20 per handle, paid entirely to the treasury |
MONTH | 30 days |
| Year | 12 months = 360 days, billed as 10 months. Every full 12 months is billed as 10, e.g. 14 months bill 12 |
MAX_UNITS | 120 (at most 120 months or 120 packs in one payment) |
MAX_ITEMS | 32 items per product |
MAX_NAME_LENGTH | 64 bytes (product and item names) |
MAX_URL_LENGTH | 200 bytes (merchant url) |
Amounts are in the smallest unit of the chain's stablecoin. Dollar constants are scaled to the token's precision
(multiplied by 10^(decimals − 6)), so one contract serves 6-decimal USDC on Arc, Base and Polygon and 18-decimal
USDC on BSC alike. A single payment is at most 2^96 − 1 units (PaymentOverflow otherwise).
#1.4 Model and rules
Merchant
| Field | Changeable |
|---|---|
| handle | Never |
| owner | Yes, in two steps: transferMerchant(merchantId, newOwner) → acceptMerchant(merchantId) (a zero newOwner cancels) |
| payTo (payout address) | Yes, setMerchant(merchantId, payTo, url) |
| url | Yes, setMerchant(merchantId, payTo, url) |
All revenue of all of a merchant's products is paid to its payTo. A merchant is registered with
registerMerchant(handle, url, payTo), which costs merchantFee().
Products belong to a merchant. A product's key never changes; its name can (setProduct(productId, name)).
Products are created with createProduct(merchantId, key, name, items) and their items are added or changed with
setItem(productId, item). A product has three kinds of items. An item's kind never changes; items can be
deactivated (active=false) but never removed. A changed price applies to later payments only; paid memberships keep
their end.
| kind | Value | Fields | Purchase function | Rules |
|---|---|---|---|---|
| Registration | 0 | — | — | Appears only in Paid events (the merchant registration fee) |
| Tier | 1 | level > 0, price = monthly price, credits = 0 | subscribe(productId, tierId, months, beneficiary, expectedPrice, orderId) / upgrade(productId, tierId, months, expectedRevision, expectedPrice, orderId) | A user has at most one membership per product |
| Lifetime | 2 | price, level = 0, credits = 0 | buyLifetime(productId, itemId, beneficiary, expectedPrice, orderId) | Each beneficiary can buy it once (AlreadyOwned) and owns it forever, independent of tiers |
| Pack | 3 | price per pack, credits > 0 per pack, level = 0 | buyPack(productId, packId, quantity, beneficiary, expectedPrice, expectedCredits, orderId) | Repeatable. The chain keeps only the receipt; the merchant keeps the credits off chain |
Every price is at least minPrice() (PriceTooLow).
Every payment is pulled from the caller (the payer) with ERC-20 transferFrom, so the payer first approves the
contract for at least the total. In the same transaction it is split between the merchant's payTo (total − fee) and
the treasury (fee); the contract never holds funds.
Membership rules
| Situation | Function | Rule |
|---|---|---|
| First purchase, or any tier after the membership has ended | subscribe | New generation, start = now |
| Same tier (switching monthly to yearly included) | subscribe | Allowed at any time, and anyone can pay for the beneficiary. Adds time to end; the generation stays the same |
| A different tier while the membership is running | subscribe | Reverts with TierMismatch |
| Upgrade: a strictly higher level | upgrade | Only the beneficiary themselves (the caller). Starts now at full price; the rest of the current tier is given up, with no refund. Requires expectedRevision (MembershipChangedSinceQuote otherwise) |
| Downgrade, or another tier of the same level | — | Wait until the current membership ends, then subscribe |
A membership counts during [start, end). Allowance cycles are the MONTH-long intervals from start, the last one
cut at end. A purchase after expiry and an upgrade start a new generation; every change increases revision.
Every payment carries expected* arguments (price, credits). If the merchant changed the terms since the checkout
page was loaded, the payment reverts with TermsChanged. An orderId must be nonzero (ZeroOrderId) and can be used
only once per payer (OrderUsed), so a retried payment can never succeed twice.
#1.5 Events (indexers depend on them, 🔒)
| Event | Purpose |
|---|---|
Paid(merchantId, productId, beneficiary, payer, kind, itemId, units, amount, fee, orderId) | The only event for moving money, merchant registration included (then productId = 0, itemId = 0, orderId = 0, units = 1, beneficiary = payer = the registrant, amount = fee = merchantFee(), i.e. 20e6 at 6 decimals). Summing it gives all revenue: merchant net revenue = amount − fee; sub3 revenue = fee |
MembershipChanged(productId, user, tierId, action, generation, revision, start, end, orderId) | A membership changed. action: 1 = purchase, 2 = renewal, 3 = upgrade |
MerchantRegistered, MerchantUpdated, MerchantTransferStarted, MerchantTransferred | Merchant lifecycle |
ProductCreated, ProductUpdated, ItemSet | Product and item lifecycle |
Initialized(token, decimals) | The stablecoin was set (once per chain) |
#1.6 Functions, errors and views
Function signatures, error names and the return structures of views are defined by the ABI JSON and are not repeated here. Writes are listed in §1.4. Reads:
| Group | Views |
|---|---|
| Setup and constants | token(), treasury(), feeFlat(), minPrice(), merchantFee(), and the public constants FEE_BPS, MONTH, MAX_UNITS, MAX_ITEMS, MAX_KEY_LENGTH, MIN_HANDLE_LENGTH, MAX_NAME_LENGTH, MAX_URL_LENGTH, PURCHASE, RENEWAL, UPGRADE |
| Records | getMerchant(merchantId), getProduct(productId), itemIdsOf(productId), getItem(productId, itemId) |
| What a user has | membershipOf(productId, user), ownsLifetime(productId, itemId, user), orderOf(payer, orderId) |
| Prices | quote(productId, itemId, units) → (total, fee, net) |
| Statistics | globalStats(), merchantStats(merchantId), productStats(productId), itemStats(productId, itemId) |
Lists (paged with offset, limit; oldest first, new entries appended) | allMerchants(offset, limit), merchantsOf(owner, offset, limit), allProducts(offset, limit), productsOf(merchantId, offset, limit), productsBoughtBy(user, offset, limit), customers(productId, offset, limit), payments(productId, offset, limit), ordersOf(productId, user, offset, limit) |
When reading:
orderOf(payer, orderId)returningpaidAt == 0means the order does not exist.membershipOfreturns(Membership, active);tierId == 0means the user never subscribed.- Statistics counters wrap around on overflow instead of reverting, so they can never block a payment. Statistics do not include merchant registration fees.
#2. HTTP API v1 🧊
Base URL, the same for the API and the hosted pages (§3) of an environment:
| Environment | URL |
|---|---|
| Production | https://sub3.dev (Arc Mainnet) |
| Sandbox | https://sub3.dev/sandbox (Arc Testnet) |
This document uses the production URLs. In the sandbox every path is the same under https://sub3.dev/sandbox
(/sandbox/v1/access, /sandbox/login, /sandbox/.well-known/jwks.json); the answers have the same shape, on the
sandbox's network, with its own sign-in keys. The api and site fields of deployments/<chainId>.json hold the
same base URLs.
The API is free, needs no API key and has no database: every answer is read from the contract. Every response allows
any origin (Access-Control-Allow-Origin: *).
General conventions:
- Parameters:
productis eitheracme/appor a0xproductId.itemis either a key (pro) or a0xitemId.- Addresses are case-insensitive. Addresses in responses may be checksummed; compare them case-insensitively.
- Amounts come both as a display string
"5.00"(no suffix) and as an integer string in token units"5000000"(suffixUnits).*Unitsare the stablecoin's smallest unit; the precision isdecimalsin/v1/network. - Times are Unix seconds (numbers).
- Errors have the body
{"error": "<message for people>", "code": "<machine-readable>"}.
| HTTP | code | What the integrator should do |
|---|---|---|
| 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 later; the response has a Retry-After header |
| 502 | unavailable | Reading the chain failed, or the contract is not deployed yet. Treat as "unknown", never as "not paid" |
| 500 | internal | — |
- Rate limits per IP address and minute: 600 read requests, 300 RPC proxy requests, 60 sign-in requests
(
/v1/auth/*), and 120 new relay connections. Server-side integrations should cache/v1/accessresults for 30–60 seconds and force a refresh after a checkout completes. - Caching (informational, ✏️): merchants, products and quotes may be up to 10 seconds old, statistics 30 seconds,
/v1/network60 seconds./v1/access,/v1/ordersand/v1/merchants?owner=are never cached.
#2.1 Network: GET /v1/network
The chain and contract of this environment.
{ "environment": "production|sandbox|local", "chainId": 5042, "name": "Arc", "token": "0x…", "symbol": "USDC", "decimals": 6,
"contract": "0x…", "explorer": "https://…", "addEthereumChain": { … }, "confirmations": 1, "testnet": false }
contractisnulluntil the contract is deployed on this chain.addEthereumChainis the parameter object for a wallet'swallet_addEthereumChain.confirmationsis the number of confirmations this environment requires (at least 1).
#2.2 Merchants
GET /v1/merchants/{handle}
{ "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 is null when the domain is not verified (rules in §4).
GET /v1/merchants/{handle}/logo
- When verified: the logo image bytes (proxied and cached).
- Otherwise: 404.
GET /v1/merchants/{handle}/products
{ "products": [ { "key": "app", "productId": "0x…", "name": "App" } ] }
GET /v1/merchants?owner=0x…
{ "merchants": [ "acme", … ] }
#2.2a Sites: GET /v1/sites
GET /v1/sites?origin=https://app.acme.com&merchant=acme
Whether the site that opened the login or checkout page (its origin, as reported by the browser) declares that it
belongs to this merchant, and its logo. Rules in §4. origin must be an https origin.
{ "origin": "https://app.acme.com", "merchant": "acme", "verified": true, "domain": "app.acme.com",
"logo": "https://sub3.dev/v1/sites/logo?origin=…&merchant=acme" }
When verified is false, domain and logo are null.
GET /v1/sites/logo?origin=…&merchant=… returns the logo image bytes, or 404 when the site does not pass.
#2.3 Products: GET /v1/products/{handle}/{key}
Also accepts /v1/products/0x<productId>.
{ "product": "acme/app", "productId": "0x…", "key": "app", "name": "App",
"merchant": { …as in 2.2, 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…" }
levelis nonzero only for tiers;creditsis nonzero only for packs.- A tier's
priceis its monthly price; a year costsprice × 10.
#2.4 Access: GET /v1/access
GET /v1/access?product=acme/app&user=0x…[&view=confirmed|latest]
To decide what a user may use, call this endpoint and nothing else.
{ "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 }
membershipisnullwhen the user never subscribed. After a membership ends it is still returned, withactive: false.cycleis the current allowance cycle (§1.4),nullunless the membership is active.cycle.id=keccak256(abi.encode(chainId, contract, productId, user, generation, index)), all integers asuint256.- A backend grants a cycle's allowance at most once per
cycle.id. - An upgrade starts a new generation, so
cycle.idalways changes.
- A backend grants a cycle's allowance at most once per
lifetimelists the keys of the lifetime items the user owns.viewdefaults toconfirmed: the state at the newest block with the required confirmations.latestreads the newest block; the checkout page uses it to refresh.
#2.5 Orders
GET /v1/orders/{payer}/{orderId}
The receipt of one order, of any kind. 404 unknown_order when it does not exist.
{ "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…" }
useris the beneficiary.unitsare months (tier), packs (pack) or 1 (lifetime).actionis alwayspurchasefor lifetime items and packs.creditsis the total granted by this order (packs only);generationandrevisionare the membership after this payment (tiers only),"0"otherwise.
GET /v1/orders?product=acme/app&user=0x…[&offset=0&limit=50]
Every order with this user as beneficiary, whoever paid; use it to recover lost callbacks. offset defaults to 0,
limit to 50 (1–100). Orders are in payment order (oldest first) and new orders are only ever appended, so a
client can store nextOffset and continue from there next time.
{ "total": "3", "offset": 0, "limit": 50, "nextOffset": "3", "orders": [ …as above… ] }
#2.6 Quotes: GET /v1/quote
GET /v1/quote?product=acme/app&item=pro&units=12
What units of an item cost now and how the payment is split. units defaults to 1 and is 1–120 (exactly 1 for
lifetime items). Example for a $10/month tier, 12 months:
{ "units": 12, "total": "100.00", "fee": "1.05", "net": "98.95",
"totalUnits": "100000000", "feeUnits": "1050000", "netUnits": "98950000" }
#2.7 Statistics: GET /v1/stats
GET /v1/stats[?merchant=acme | ?product=acme/app] (one parameter at most)
| Parameter | Returns |
|---|---|
| None | Global: { "merchants": 1, "products": 1, "revenue": "…", "revenueUnits": "…", "fees": "…", "feesUnits": "…", "payments": 0, "customers": 0 } |
merchant | The same revenue, fee, payment and customer fields for the merchant, plus its number of products |
product | The same revenue, fee, payment and customer fields for the product, plus items: [ { "key", "revenue", "revenueUnits", "payments", "customers" } ] |
revenue is what payers paid (the merchant received revenue − fees); customers counts distinct beneficiaries.
Merchant registration fees are not included.
#2.8 Sign-in and RPC
| Endpoint | Description |
|---|---|
POST /v1/auth/nonce | → {nonce, issuedAt, expiresAt, domain, uri}. A nonce is valid for 5 minutes |
POST /v1/auth/verify | Body {message, signature} → {address, audience, token, expiresAt} |
GET /.well-known/jwks.json | The public keys that sign this environment's session tokens (/sandbox/.well-known/jwks.json in the sandbox) |
POST /v1/rpc/{chainId} | Read-only JSON-RPC proxy for this environment's chain only (5042 or eip155:5042); see below |
/relay/v1/<room> | PeerConnect v1 relay (§6) |
Session tokens (JWT):
- Algorithm
EdDSA(Ed25519);kididentifies the key, for rotation. iss= the environment's base URL:https://sub3.dev, orhttps://sub3.dev/sandboxin the sandbox. Each environment signs with its own keys, so a sandbox token never verifies as a production one.aud= the integrator's origin.sub= the lowercase address.- Valid for 7 days (
exp=iat+ 7 days).
Signatures are checked as plain wallet signatures, or with ERC-1271 for smart accounts, on this environment's chain.
SIWE message template 🧊: server/src/api/auth.rs is authoritative, and website/src/lib/siwe.ts must match it
byte for byte:
{domain} wants you to sign in with your Ethereum account:
{checksummed address}
Sign in to {integrator origin} with sub3.
URI: {uri}
Version: 1
Chain ID: {chainId}
Nonce: {nonce}
Issued At: {issuedAt}
Resources:
- {integrator origin}
domain and uri come from /v1/auth/nonce: sub3.dev and https://sub3.dev/login (in the sandbox,
sub3.dev and https://sub3.dev/sandbox/login).
RPC proxy: the body is a JSON-RPC request or a batch of at most 32, at most 64 KB. Allowed methods:
eth_chainId, eth_blockNumber, eth_call, eth_estimateGas, eth_getBalance, eth_gasPrice,
eth_maxPriorityFeePerGas, eth_feeHistory, eth_getTransactionReceipt, eth_getTransactionByHash,
eth_getTransactionCount, eth_getCode. eth_call and eth_estimateGas may only target the sub3 contract or the
stablecoin (403 forbidden otherwise).
#3. Hosted pages 🧊
Site (the base URL of §2):
| Environment | URL |
|---|---|
| Production | https://sub3.dev |
| Sandbox | https://sub3.dev/sandbox |
#3.1 How pages are opened, and how results come back
A page can be opened in two ways:
- Popup: the page sends
{type:"sub3:ready"}towindow.opener, and the integrator answers{type:"sub3:hello"}within 4 seconds. The page takes the answer'sMessageEvent.originas the site, and from then on sends results to that origin only, then closes itself:- Success:
{type:"sub3:result", result} - Cancel:
{type:"sub3:cancel", reason}
- Success:
- Redirect: given with a
return=<url>parameter (anhttporhttpsURL). The page takes that URL's origin as the site and navigates back to the URL with the result as its fragment (replacing any fragment it had), so the result never reaches a server log:- Success:
#sub3=<base64url(JSON)>(UTF-8 JSON, unpadded base64url) - Cancel:
#sub3_error=<urlencoded reason>
- Success:
If a popup gets no sub3:hello in time, the page falls back to return when it is given.
Cancel reasons: cancelled (the user cancelled), already_owned (checkout: the user already owns this lifetime
item). Integrators must accept reasons they do not know.
#3.2 /login
Parameters: ?[return=…][&merchant=acme] (return is needed for redirects only)
- If
merchantis given and the opening site's/.well-known/sub3.jsonnames that merchant (§4), the page shows the site's logo; otherwise only its origin. - Result:
{ "address", "token", "expiresAt", "siwe": { "message", "signature" } }.tokenis the session token (§2.8);siweis the signed sign-in message, for integrators that verify the signature themselves.
#3.3 /checkout
Parameters: ?product=acme/app&item=pro[&units=1][&user=0x…][&return=…]
product and item accept the same forms as the API (§2).
units:
| Item kind | Meaning | Default |
|---|---|---|
| tier | Months (12 = a year) | 1 |
| pack | Number of packs | 1 |
| lifetime | Ignored | — |
units is a whole number from 1 to 120. When it is given, the page charges exactly that; when it is not, the page
starts at 1 and lets the payer choose.
user: the beneficiary. Defaults to the paying wallet.
The page decides the action from the chain:
| State | Action |
|---|---|
| Never subscribed, or the membership has ended | Purchase |
| The same tier is running | Renewal |
| A lower tier is running and the payer is the beneficiary | Upgrade. The page states clearly that the rest of the current term is given up, and the user must confirm before paying |
| Anything else | Shows "can be changed after the current membership ends", with the end date |
Price, payee and action always come from the chain, never from the link. The page always shows the merchant's identity (logo, @handle, verified domain).
Result:
{ "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…" }
txHash is null when the page found the order already paid (for example, a retry after a reload).
A checkout result is only a hint to refresh. Integrators must check it with /v1/access or
/v1/orders/{payer}/{orderId} and never trust the callback itself.
#4. Merchant and product website verification 🧊
Two identities, one file:
| Identity | Where the website comes from | Shown on |
|---|---|---|
| Merchant | The merchant url registered on chain (set by the merchant owner) | Checkout, "who you pay": merchant logo, @handle, domain ✓ |
| Product website | The site that opened the login or checkout page; the browser reports its origin, nothing to set up | Checkout, "what you buy", and login: that website's own logo and domain |
Both are verified by reading the website's /.well-known/sub3.json, whose merchant must equal the merchant's
handle. A merchant can have any number of product websites, each with its own copy of the file. A website can claim
any merchant, but payments always go to the merchant on chain, so the claim affects only what is displayed, never
where money goes; the merchant's own ✓ is what users trust.
The same rules apply to both:
- The merchant
urlmust start withhttps://and use the default port. The verifieddomainis its host. (For a product website, its origin must behttpslikewise.) Exception: only in local development (SUB3_ENVIRONMENT=local) arehttp://localhost[:port]andhttp://127.0.0.1[:port]also accepted, so developers can preview their logo before deploying. Sandbox and production accept HTTPS only. - sub3 reads
https://<domain>/.well-known/sub3.json(it and the logo must answer 200 within 5 seconds; the file at most 16 KB):{ "merchant": "acme", "logo": "/logo.png" } - Verification passes only if all of these hold:
merchantequals the handle.logoresolves, relative to thesub3.jsonURL, to anhttpsURL: a path such as/logo.png, or a logo on any other HTTPS host (a CDN, the brand's main site). Plainhttponly on the site's own origin, which happens only in local development.- The logo answers 200 with a
png,jpeg,webporsvgcontent type and is at most 256 KB.
- A passed check is cached for 10 minutes, a failed one for 1 minute. Changing the merchant
urlis checked again at once.
Why not just read /logo.png: an impostor could set its url to someone else's website and borrow their logo. Only
whoever controls the domain can place sub3.json there, so the file is required.
#5. Entry points (for agents and people) 🧊
These URL paths on the site never change:
| Path | Content |
|---|---|
/llms.txt | The complete integration guide, in one fetch |
/examples.txt | The list of example files |
/examples/<path> | One example file |
/sub3.mjs | The management CLI (register a merchant, create products, set items, read statistics); needs viem |
/spec | This specification as a web page |
/spec.md | This specification as Markdown (text/markdown) |
/abi.json | The contract ABI, interfaces/abi/Sub3Checkout.v2.json (application/json) |
#6. Transport protocols 🔒
- PeerConnect v1: relay path
/relay/v1/<room>on the API host. The wire format is locked inserver/pair/test-vectors/v1.jsonand enforced by tests. - WalletPair v2: message formats identical to upstream
83b45d7(seewebsite/src/lib/walletpair/README.md).
#7. Browser storage keys 🧊 (Maintainers)
Used by sub3.dev's own pages; integrators never read them.
| Key | Storage | Purpose |
|---|---|---|
sub3.bridge.sessions.v1 | localStorage | Wallet bridge sessions |
sub3.dapp.room.v1 | localStorage | Pairing room |
sub3.dapp.sites.v1 | localStorage | Authorized sites |
sub3.order.v2.<key> | sessionStorage | Reuses the same orderId when a payment is retried, so it is never charged twice |
Renaming a key loses sessions, or charges twice on a retry. To change a structure, use a new version suffix and migrate the old data.
#8. Deployment configuration (Maintainers)
deployments/<chainId>.json(anddeployments/local.jsonfor local development) records{ environment, chainId, name, abi, salt, initialized, address, token, decimals, treasury, deployTx, block, api, site }.saltis the salt's preimage (sub3.checkout.v2);addressisnulluntil deployed.- One Worker (
server/, thesub3Worker onsub3.dev) serves the website, the API and the relay of both public environments, from one build: production at/, the sandbox under/sandbox. Production readsNETWORK,SITE,AUTH_SECRETandJWT_KEYS; the sandbox reads the same names with the prefixSANDBOX_. RPC endpoints are per chain (RPC_<chainId>). The two environments never share signing keys. - Staging (
--env staging,https://staging.sub3.dev/sandbox) runs the next release on the sandbox's network and contract with its own secrets, before it ships tosub3.dev. NETWORKandSANDBOX_NETWORKinserver/wrangler.toml(and staging'sSANDBOX_NETWORK) must agree with the deployment records (chain, token, decimals, contract);node scripts/check-interfaces.mjschecks this in CI.
#9. Changelog
| Date | Change |
|---|---|
| 2026-11-12 | One domain: the sandbox moved from sandbox.sub3.dev and api-sandbox.sub3.dev to https://sub3.dev/sandbox, and the production API from api.sub3.dev to https://sub3.dev (/v1, /relay, /.well-known/jwks.json under each base). The sandbox's token issuer is https://sub3.dev/sandbox. Sub3Checkout deployed at 0x5F7c…dF7 on Arc Mainnet and Arc Testnet |
| 2026-11-12 | Specification rewritten in English as "sub3 Specification v1" and published at /spec, /spec.md, with the ABI at /abi.json. Corrected to match the code: the login result also carries siwe; checkout txHash can be null; cancel reasons listed; /v1/stats includes feesUnits; Initialized event; registration Paid uses merchantFee(); logo URLs may be any same-origin reference; deployment records list their real fields |
| 2026-11-12 | The constructor takes only the treasury; the Safe sets the token with initialize, 6–18 decimals supported, same address on every chain; dollar constants became feeFlat() / minPrice() / merchantFee() |
| 2026-11-12 | v2 contract and API v1 finalized (not yet on mainnet). Removed the owner, notBefore and the old standalone plan mode; added merchants, three item kinds, the single Paid event, /v1/access and /v1/orders |