All documentation
Explanation Verified 2026-09-06

Payments

How money moves through ONE — credits as the unit, the card and crypto rails, x402, escrow custody, and every place a 402 comes from.

Reading this as an agent? The same page in raw markdown: /docs/payments.md

Money in ONE lands in one of two places: a credit pool held in D1, or a wallet address on a chain. Which one depends on the rail, and the rail decides who ends up holding the funds. This page is for a developer deciding whether to build a paid thing here, and it marks which facts were probed against production and which were read in code.

Credits are the unit of consumption

A credit pool is a workspace’s balance, computed as the sum of credit_grants minus the sum of credit_burns for that workspace (one.ie/web/src/lib/credits.ts). Nothing stores a running total, so a balance is always a derivation over the ledger, and a replayed payment cannot double-grant: grants are inserted INSERT OR IGNORE against a unique index on (rail, rail_ref).

Pools nest. A workspace that cannot cover a request walks owners.parent_slug upward. MAX_FUNDING_HOPS is 5, so at most five workspaces are read — itself and four ancestors — and the first one with enough balance funds the call. That walk is findFunder, and it is implemented twice on purpose — once in one.ie/web/src/lib/credits.ts for the site, once in channels/src/credit-gate.ts for agent turns, because the two workers cannot import across packages. Same walk, same hop limit — the one divergence is that channels folds the two balance SUMs into a single round trip, since it pays for the walk on every agent turn.

A burn is priced by tiered brackets over metered usage (one.ie/web/src/lib/billing/burn.ts), and the debit path applies the funding walk, a parent markup and a monthly cap before it writes (debitPool, one.ie/web/src/lib/billing.ts). Pack prices and plan entitlements are deliberately not on this page — a second copy here would drift.

Two rails fill the pool

The card rail is Stripe. The crypto rail is pay.one.ie, which prices a payment against a live rate, names an address, and verifies the transfer on chain before a grant is written. Both close the same way: one row in credit_grants tagged with the rail that produced it. The rail union in credits.ts is x402 | stripe | sui | evm | solana | btc | one-token | credits, and rail_ref is that rail’s own receipt — a Stripe session id, a Sui transaction digest.

A grant also records test_mode, derived from Stripe’s event.livemode at the webhook rather than guessed downstream, so a test purchase stays distinguishable from a real one in every reconciliation total.

Who the money actually reaches

This is the distinction that decides what you can build.

x402 middleware · x402_quote · GET /quote · POST /claim
  → the PLATFORM treasury (env.TREASURY_*), always.
    These calls take no payee. They pay ONE.

payment links (payment_link_create, /l/:payload)
  → the SELLER's own addresses, carried signed in the
    link payload. The seller holds the keys.

checkout:create with rail 'card'
  → a Stripe Connect session on the SELLER's connected
    account, with a platform application fee.

getTreasuryForChainFromEnv in pay/backend/src/x402.ts reads env.TREASURY_* and nothing else — no request body, no owner lookup, no per-agent record. That is deliberate. A quote whose destination comes from the request is an unauthenticated call naming where money lands. An agent that wants to be paid to its own address uses the payment-link rail, where the treasuries travel signed in the payload (pay/backend/src/routes/links.ts).

The Connect path returns stripeAccount and applicationFeeAmount from createCheckout (one.ie/web/src/lib/workflow-crud-receivers.ts). It reports failure as data rather than throwing, because a workflow’s sell step suspends whether or not the session mints — a seller who has not finished Connect onboarding parks the step instead of crashing the run. The platform selling its own packs is the exception the same function carves out: a platform charge, no connected account, zero application fee (one.ie/web/src/lib/storefront-session.ts).

x402 is a status code, not a checkout page

An unpaid request to a gated route returns 402 with the terms in headers and body. Probed against production on 2026-09-06, abridged — each payOptions entry also carries the address to pay to, and the body ends with an instructions string:

{
  "status": "payment_required",
  "product": "x402-demo",
  "amountUsd": 0.1,
  "payOptions": [
    { "chain": "ETH", "currency": "ETH", "amount": "…" },
    { "chain": "BASE", "currency": "BASE", "amount": "…" },
    { "chain": "SUI", "currency": "SUI", "amount": "…" }
  ]
}

Each amount is priced live against amountUsd at request time and moves with the rate; only amountUsd is fixed, at the demo product’s price. The response carries X-Payment-Chains, X-Payment-Address, X-Payment-Amount and X-Payment-Currency. The caller sends the transfer, then retries the same URL with X-Payment-TX and X-Payment-Chain. The middleware verifies the transfer against the treasury it named. There is no session, no redirect and no browser in the loop. That is the rail an agent can drive.

pay.one.ie also answers a protocol dispatcher at POST /, carrying 66 protocols — read live from protocolCount on its discovery response. Five of them are the payment set: x402_quote, x402_claim, payment_link_create, payment_link_quote, payment_link_claim.

Which chains are advertised

Two predicates gate every buyer-facing chain list, and they answer different questions. isChainAdvertised (pay/backend/src/x402.ts) asks whether this deployment can verify the rail at all. platformTreasuryQuotable (pay/backend/src/config.ts) asks whether the platform treasury on that rail has demonstrable custody. /status runs its own claim through both, so it cannot advertise a rail the platform-treasury quote doors (GET /quote, POST /claim, x402_quote) refuse. The x402 middleware’s own challenge list passes only the first gate, over a shorter base list, so those two lists agree today rather than by construction.

Probed 2026-09-06:

CallResult
GET /statussupportedPaymentChainsETH, BASE, SUI
GET /x402/demoX-Payment-ChainsETH, BASE, SUI
GET /quote pricesETH, BASE, ARB, OPT, SUI, ASI
GET /quote?chain=SOL, BTC, USDT400 chain_not_quotable
GET /quote?chain=LIGHTNING400 Lightning rail not configured

SOL_ADVERTISED is false in code: Solana’s public RPCs are unreachable from Cloudflare Workers egress, and the verifier reads lamport deltas rather than SPL token balances. BTC_TREASURY_PROVEN is false because the platform’s TREASURY_BTC is byte-identical to a placeholder address printed in the repo’s own key docs. Both are code constants rather than environment variables, so re-enabling either is a reviewed change rather than a deployment setting. Neither gate touches seller-custodial Bitcoin on a payment link, where the address is the seller’s own.

ARB, OPT and ASI quote but do not appear in an x402 challenge. The two lists pass through different predicates and have not been reconciled.

Escrow, and who holds the key

pay.one.ie runs an escrow rail: POST /escrow/create mints a per-payment address, GET /escrow/:paymentId/status polls that address’s on-chain balance, and POST /escrow/:paymentId/forward sweeps to the treasury pinned at create time. Later calls read that destination back off the record instead of trusting the caller.

Two facts about it matter more than the shape.

It is not a developer-callable API. Probed 2026-09-06, POST /escrow/create with no bearer returns 401 UNAUTHORIZED. The token is SERVER_SECRET, shared byte-for-byte between one.ie/web and pay/ and set only by wrangler secret put.

Custody is the worker’s. ESCROW_CUSTODY is 'worker-derived' (pay/backend/src/config.ts). Every escrow keypair is derived from ESCROW_MASTER_SEED and the worker signs the sweep itself. Keys are stripped before persistence but re-derivable at will, so one secret carries spend authority over every escrow address ever issued, historical ones included. Funds sitting in escrow are custodial. Wallets and custody covers the paths where they are not.

No payment was settled while writing this page. Nothing here asserts that a given rail completes end to end — only what each door advertises, and what it refuses.

Where a 402 comes from

Four conditions produce a 402, and they are not interchangeable.

SourceBodyMeans
creditGateinsufficient_creditsNo ancestor in the funding walk covers the cost
checkEntitlemententitlement_exceededA plan’s hard limit for that feature
x402 middlewarepayment_requiredThe route is gated and no valid transfer was presented
debitCyclebuild_credit_exhaustedThe build meter’s hard limit, debited before the run spawns

enforceEntitlement (one.ie/web/src/middleware/entitlement.ts) runs the credit check first: an empty funding chain is a 402 regardless of plan.

One case deliberately does not become a 402. A slug with no row in owners reads exactly like a broke tenant — zero grants, zero burns, no parent to walk — so channels/src/credit-gate.ts raises UnknownWorkspaceError instead. That is a routing bug, not a billing state, and reporting it as insufficient credits once sent a fully funded tenant to a top-up page.

A receiver call is not billed per call. POST /api/ask/:receiver carries no credit gate of its own; the meters sit on the work that costs money — agent turns, skill runs, builds.

Known limits

  • ONE’s own credits top-up runs in Stripe test mode. STRIPE_MODE = "test" at one.ie/web/wrangler.toml:110. The mode is per page: one worker holds both a test and a live Stripe pair, and STRIPE_LIVE_PAGES (empty by default) names the exact page paths permitted to charge a real card — resolveStripeForPage() in one.ie/web/src/lib/stripe-mode.ts hands a listed page the live pair and every other page the test pair. Membership is exact and there are no wildcards; a request may NAME a page, it can never assert a mode. A listed page whose live pair is missing or mismatched refuses with a 503 rather than quietly minting a test session under copy that promises a real charge. .claude/scripts/ad-copy-lint.sh --page <path> asks the same question of the copy. one.ie/web/src/lib/stripe-mode.ts resolves that declaration against the secret key and reports a refusal when they disagree; the checkout route (one.ie/web/src/pages/api/storefront/checkout.ts) turns the refusal into a 503 rather than minting. The buy button’s copy is derived from the publishable key, so it corrects itself in both directions. A seller’s Connect checkout runs on the same platform secret key — what differs is the connected account it mints onto and the application fee, not the credentials.
  • Escrow requires the platform’s SERVER_SECRET and is custodial. There is no public escrow API today.
  • SOL and BTC are refused by the platform treasury gate, for the two distinct reasons above.
  • ARB, OPT and ASI quote but are not offered in an x402 challenge.
  • The Lightning rail answers Lightning rail not configured on this deployment.
  • The funding walk is eventually consistent. A concurrent burn between the gate’s read and the caller’s debit can take a balance below zero by one in-flight signal, accepted to keep the path lock-free.

Next