x402 payments
x402 uses HTTP 402 Payment Required as a protocol: the server answers an unpaid request with the terms it accepts, the client pays, and the request is repeated with a receipt attached. No account and no API key are involved.
@lelantos-org/sdk/x402 implements the payer. x402(wallet, options) wraps fetch so a 402 is settled from the shielded pool and the request retried once. The result is an ordinary fetch, accepted anywhere one is.
import { x402 } from "@lelantos-org/sdk/x402";
await wallet.sync();
const pay = x402(wallet, { budget: { total: "5" } });
const data = await pay("https://api.example.com/premium").then((r) => r.json());x402() returns synchronously and makes no request until the wrapped fetch is first called. Any response other than a 402 is passed through untouched.
The server sees a payment rather than a customer: a shielded transfer carries no sender identity, and on-chain observers see a MASP transaction. The request itself is ordinary HTTPS and the server reads it — see Privacy checklist.
A budget is required
An agent chooses its own purchases, so the exposure is a long run of individually valid payments. budget.total is required; omitting it throws before the wrapper is built.
import { x402 } from "@lelantos-org/sdk/x402";
const pay = x402(wallet, {
budget: { total: "5", perRequest: "0.5" }, // human units, per asset
allowHosts: ["api.example.com"],
});| Field | Meaning |
|---|---|
budget.total | cumulative ceiling for the lifetime of this payer. Required |
budget.perRequest | ceiling for any single payment. Defaults to total |
allowHosts | hostnames this payer may pay. Unset means any host |
Limits are human decimal units applied per asset: { total: "5" } is five of each asset, not five across all of them, because assets are not comparable without a price oracle. The ledger belongs to the x402() call rather than the process, so two payers built from one wallet hold independent budgets.
The budget counts what is paid to servers, not the relayer fee on each shielded payment. Cap that with acceptRelayerFee on the wallet the payer runs on. See Limiting the relayer fee.
A payment being minted is reserved against the budget until it settles. Proving takes seconds; without the hold, concurrent payments would each pass the check before any was recorded.
import { formatAmount } from "@lelantos-org/sdk";
import { x402 } from "@lelantos-org/sdk/x402";
const pay = x402(wallet, { budget: { total: "5" } });
// … calls …
for (const [asset, amount] of pay.spent()) {
console.log(formatAmount(amount, await wallet.asset(asset), { symbol: true }));
}spent() returns circuit units keyed by MASP asset id. See Amounts and assets.
What happens on a 402
| Step | |
|---|---|
| 1 | the wrapped fetch sends the request; the server answers 402 with a PAYMENT-REQUIRED header |
| 2 | the host is checked against allowHosts |
| 3 | one accepts[] entry is selected and priced, and the amount reserved against the budget |
| 4 | the mechanism mints the payment — for a shielded one, wallet.transfer and its Groth16 proof |
| 5 | the reservation is committed and the identical request retried with a PAYMENT-SIGNATURE header |
| 6 | onPayment fires with the settlement receipt, and the response is returned |
A payment is attached to at most one retry
A paid request that still answers 402 throws payment-rejected and is not paid again: the funds are spent and a blind retry would spend more. The wrapper also sits outside the SDK's 5xx retry logic, so a transient failure on the retry never re-runs a payment.
A failure while minting releases the reservation. Nothing is spent and the budget is unchanged.
Choosing among the server's offers
A 402 may carry several offers. Selection walks them shielded networks first, then in the server's own order, and settles the first this wallet can satisfy. An unsatisfiable offer is skipped rather than fatal:
| Skipped when | |
|---|---|
| no mechanism is registered for the scheme and network | an eip155:* offer with allowUnshielded off |
| the offer settles on another chain, or names another pool | no bridging |
| a shielded offer's asset is not covered | asked as the spend asks it, so the relayer fee counts |
| an unshielded offer cannot be funded | neither the payer address nor a top-up from the pool covers it |
maxTimeoutSeconds is below DEFAULT_MIN_TIMEOUT_SECONDS (20) | the window would close before the proof was ready |
Payability is what supports multi-asset pricing: a server may offer one resource in USDC and in WETH, and the payer settles whichever it can pay. Only the mechanism can judge this — the wallet's notes for a shielded offer, a throwaway address's ERC-20 balance for an unshielded one — so selection asks it rather than pricing offers itself. Both answer before anything moves, which is what makes an offer this wallet cannot pay a skip rather than a failed request.
A budget breach aborts instead of falling through — dropping to a cheaper offer would hide that the ceiling was reached. When nothing is payable, no-acceptable-requirements names each offer tried and why it was skipped.
Shielded payments
shieldedExact implements scheme: "exact" on network: "shielded:<chainId>". A shielded transfer has exact semantics, so this is a network family under the existing scheme rather than a new scheme. extra.pool is the only pool-specific field; this SDK produces and accepts LELANTOS_POOL.
The flow is upfront: the transfer is submitted before the resource is served, and the payload is its receipt — pool, transaction hash, and the recipient note's commitment. x402's authorization flow would require a facilitator able to relay a Lelantos bundle and is not supported. Upfront payment exposes the payer to a server that takes payment without answering, which is the second reason budget is required.
import { x402 } from "@lelantos-org/sdk/x402";
const pay = x402(wallet, {
budget: { total: "5" },
shielded: {
onPhase: (phase) => console.log(phase), // "proving" takes seconds
autoConsolidate: true, // merge notes when no single spend covers the price
minTimeoutSeconds: 30,
},
});The commitment the server verifies comes from the receipt's recipientCommitment, not a fixed output slot: output order is randomized, and the payer's change commitment would not verify. See Transfer.
Unshielded payments
Many x402 servers accept only EIP-3009 on a public chain. allowUnshielded: true registers unshieldedExact, which pays them by unshielding into a throwaway address.
import { x402 } from "@lelantos-org/sdk/x402";
const pay = x402(wallet, {
budget: { total: "5" },
allowUnshielded: true,
unshielded: {
topUpMultiple: 10n, // unshield up to ten payments' worth per proof
onTopUp: ({ payer, asset, amount }) => console.log("funded", payer, asset, amount),
},
});It is off by default because it trades privacy for reach. The observable link becomes "a Lelantos withdrawal funded this address" rather than "this account paid this API": payer keys are derived from nsk, so they are unlinkable to the wallet's signer address and recoverable from the same seed on any machine. Each resource host gets its own payer slot by default, since one shared address would let servers link payments by comparing from.
| gas | none: EIP-3009 is a signed authorization the server's facilitator submits |
| tokens | EIP-3009-capable only (USDC and similar); most ERC-20s are not |
| chains | the chain the MASP is deployed on. An offer on another chain is refused, not bridged |
| top-ups | not counted against the budget: value moves to an address the caller controls, and counting it would double-count every payment made from it. onTopUp makes them observable |
| routing | an offer the payer address and the pool together cannot fund is skipped, like any other offer this wallet cannot pay, before an authorization is signed |
topUpMultiple | an amortisation, not a requirement: a pool holding less than the multiple still funds the payment itself, rather than the offer being refused |
When a payment is refused
Every refusal below means no funds moved: the checks run before wallet.transfer or wallet.withdraw is called.
import { isWalletError } from "@lelantos-org/sdk";
try {
await pay("https://api.example.com/premium");
} catch (err) {
if (isWalletError(err, "X402_PAYMENT")) {
console.warn("not paid:", err.reason, err.resource);
} else {
throw err;
}
}reason | Meaning | Response |
|---|---|---|
budget-exceeded | cumulative spend for this asset would pass budget.total | a policy stop; surface it to the operator |
per-request-limit | this payment is over budget.perRequest | the resource costs more than the agent may spend |
host-not-allowed | the host is not in allowHosts | correct the allowlist, or do not call it |
no-acceptable-requirements | nothing in accepts[] is payable by this wallet | check the asset, chain and balance named in the message |
unsupported-requirements | malformed, or another chain or pool | usually a server-side fault |
payment-rejected | the server answered 402 again after payment | do not retry; the funds are spent |
resource is a field rather than part of context, which error reporters serialise in full: the URL identifies the paid APIs this wallet calls. See Errors.
Auditing what was paid
import { x402 } from "@lelantos-org/sdk/x402";
const pay = x402(wallet, {
budget: { total: "5" },
onPayment: (record) => {
record.url;
record.requirements.amount; // the accepts[] entry that was satisfied
record.unshielded; // true when this payment left the pool
record.settlement?.transaction; // the server's receipt, when it sent one
},
});onPayment fires after each successful payment and its errors are swallowed, so an audit sink that throws cannot fail the request.
Using an existing x402 client
x402() covers the common case. With an existing x402Client — to combine with another chain's mechanism, or to use @x402/mcp — register the mechanisms directly; both are structurally SchemeNetworkClient.
import { shieldedExact, shieldedNetwork } from "@lelantos-org/sdk/x402";
const chainId = await wallet.chain.chainId();
client.register(shieldedNetwork(chainId), shieldedExact(wallet));Registering directly opts out of the budget, host allowlist and spend accounting in x402(); the mechanisms enforce none of them.
Verifying a presented receipt — the payee side — uses a watch-only wallet instead of this module: the decrypted note gives the asset and value, and confirmCommitment establishes that the pool holds it. See Confirming a payment against the pool.