Skip to content

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.

ts
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.

ts
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"],
});
FieldMeaning
budget.totalcumulative ceiling for the lifetime of this payer. Required
budget.perRequestceiling for any single payment. Defaults to total
allowHostshostnames 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.

ts
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
1the wrapped fetch sends the request; the server answers 402 with a PAYMENT-REQUIRED header
2the host is checked against allowHosts
3one accepts[] entry is selected and priced, and the amount reserved against the budget
4the mechanism mints the payment — for a shielded one, wallet.transfer and its Groth16 proof
5the reservation is committed and the identical request retried with a PAYMENT-SIGNATURE header
6onPayment 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 networkan eip155:* offer with allowUnshielded off
the offer settles on another chain, or names another poolno bridging
a shielded offer's asset is not coveredasked as the spend asks it, so the relayer fee counts
an unshielded offer cannot be fundedneither 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.

ts
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.

ts
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.

gasnone: EIP-3009 is a signed authorization the server's facilitator submits
tokensEIP-3009-capable only (USDC and similar); most ERC-20s are not
chainsthe chain the MASP is deployed on. An offer on another chain is refused, not bridged
top-upsnot 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
routingan 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
topUpMultiplean 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.

ts
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
;
} }
reasonMeaningResponse
budget-exceededcumulative spend for this asset would pass budget.totala policy stop; surface it to the operator
per-request-limitthis payment is over budget.perRequestthe resource costs more than the agent may spend
host-not-allowedthe host is not in allowHostscorrect the allowlist, or do not call it
no-acceptable-requirementsnothing in accepts[] is payable by this walletcheck the asset, chain and balance named in the message
unsupported-requirementsmalformed, or another chain or poolusually a server-side fault
payment-rejectedthe server answered 402 again after paymentdo 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 ​

ts
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.

ts
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.

Next ​