x402 turns HTTP 402 Payment Required into a
machine-payable rail with no accounts, API keys, or webhooks: an agent pays per
request in stablecoin (USDC) and a third-party facilitator verifies and
settles the payment on-chain.
@lunora/x402 gives a Lunora app both sides of that exchange, as two
independently tree-shakeable subpaths:
| Subpath | Rail | Who uses it |
|---|---|---|
@lunora/x402/charge | charge | Your deployment sells: gate an HTTP-action route, a procedure, or an MCP tool behind a price. |
@lunora/x402/pay | pay | Your action/agent buys: pay an x402-gated resource on the way out. |
The root export (@lunora/x402) carries only the shared config/types
(X402Network, FacilitatorConfig, price helpers).
pnpm add @lunora/x402Networks. EVM chains (Base, Arbitrum, Ethereum, Polygon, …) are signed via
@x402/evm + viem; pass a raw CAIP-2 id for chains without a friendly alias.
Solana is signed via @x402/svm. The facilitator defaults to the public,
Coinbase-operated https://x402.org/facilitator (no key required); override it
via facilitator: { url, headers } for a self-hosted or keyed CDP facilitator.
The charge rail: sell a resource
The server side needs only a recipient address, not a private key, because
the facilitator performs settlement. Every gated surface runs the same
four-step flow: unpaid request → 402 + PAYMENT-REQUIRED challenge; the
client's PAYMENT-SIGNATURE payload is verified (via the facilitator); then the
handler and settlement run, in an order that depends on the rail (see below);
finally PAYMENT-RESPONSE is attached to the returned resource.
Header names: x402 v2 only. A paying client sends the signed payload as
PAYMENT-SIGNATURE and reads the receipt from PAYMENT-RESPONSE, which is what
@x402/fetch does. The v1 names (X-PAYMENT, X-PAYMENT-RESPONSE) are not
accepted: @x402/core v2 does not read X-PAYMENT, so a v1 client gets a fresh
402 and is not charged.
A paid response is never shared-cacheable. Whatever cache headers the
handler set, the paid response goes out Cache-Control: private (a public or
s-maxage directive is dropped), and every CDN-targeted cache header —
CDN-Cache-Control, any <vendor>-CDN-Cache-Control such as
Cloudflare-CDN-Cache-Control, and Surrogate-Control — is removed, since a CDN
obeys those ahead of Cache-Control. So no CDN or proxy can store one caller's
paid body and replay it to the next. The payer's own browser may still keep a
private copy for the max-age the handler set; only the payer, who already has
the body, can reuse it. Send Cache-Control: no-store from the handler if even
that is unwanted.
Gate an HTTP action
withX402 wraps a (ctx, request) => Response handler behind a paywall:
import { httpAction } from "@lunora/server";
import { withX402 } from "@lunora/x402/charge";
export const report = httpAction(
withX402({ network: "base", price: "$0.05", recipient: { evm: env.PAYOUT } }, async (ctx, request) => {
return Response.json(await ctx.runQuery(api.reports.latest, {}));
}),
);Settlement order: the HTTP-action rail settles before the handler runs
(createChargeMiddleware's settleBeforeHandler default). A payment that
passes verify but fails /settle — an underfunded wallet, a facilitator
refusal — therefore never reaches the handler's side effects at all. Once
settlement succeeds the payment is final on-chain, so a handler failure after
that point is an ordinary application error, not a payment to unwind.
Settle-after is the opt-in: it passes the handler's response to settlement
as transport context (responseHeaders, read by some schemes for settlement
overrides) and releases a verified-but-unsettled payment when the handler
throws. withX402 does not expose it — build the middleware directly when you
need it, and note that settlement can still fail after the handler already ran,
so such a handler must be idempotent or compensatable:
import { createChargeMiddleware } from "@lunora/x402/charge";
const charge = await createChargeMiddleware(config, undefined, { settleBeforeHandler: false });
export const report = httpAction(async (ctx, request) => charge.handle(request, () => handler(ctx, request)));Gate a procedure
Tag a public query/mutation/action as paid with the .x402({ price })
builder modifier; the origin worker challenges before dispatch. The
worker-level settlement vocabulary (network, recipient, facilitator) is
injected once via createProcedureChargeGate, so @lunora/runtime never
imports @lunora/x402 (keeping viem/solana out of unpaid worker bundles):
import { createProcedureChargeGate } from "@lunora/x402/charge";
const gate = createProcedureChargeGate({ network: "base", recipient: { evm: env.PAYOUT } });
// handed to createWorker({ x402Charge: gate })Each paid function bakes its own price and its functionPath as the challenge
resource. Internal functions have no .x402: they are server-to-server and
never client-reachable, so there is nothing to charge.
The worker must carry the function registry. The .x402 tags live on
createWorker({ functions }) — defineApp() supplies it, a hand-rolled
createWorker({ shardDO }) or a bare createLunoraHandler() does not. Without it
the origin cannot see that a procedure is paid, so it charges nothing; the shard
refuses that dispatch with MISCONFIGURED rather than serving the paid result
free. If a paid procedure starts answering 500, the worker is missing
functions.
A paid procedure exposed over REST cannot declare a public cache. The REST
edge cache answers before the paywall runs, so .x402({ price }) together with
.expose({ rest: true, cache: { scope: "public" } }) would hand the paid result
to every later caller for free. createWorker refuses that combination at
construction; use scope: "private" or no cache policy.
Settlement order: the procedure gate settles before dispatching the function (settle-first), because dispatch is what commits a paid mutation's writes. A settlement failure therefore means the function never runs at all; there is no window where a paid mutation's write commits without the payment having settled. Once settlement succeeds the payment is final (on-chain); a function failure after that point is a normal application error, not a payment to unwind.
A lost response is paid twice. Settle-first (both rails) consumes the
payment's nonce before the handler runs, so if the response never reaches the
client — a dropped connection, a timeout — re-sending the same PAYMENT-SIGNATURE fails
verification and earns a fresh 402; @x402/fetch then signs a new payload and
pays again. Where a double charge matters, make the handler idempotent (key it on
something the client controls, e.g. an Idempotency-Key header or a client-minted
id in the args) and cache the receipt client-side so a retry replays the result
rather than the payment — or, on the HTTP-action rail, opt into settle-after,
which never settles until the handler has produced a response.
Paid MCP tools
@lunora/mcp's createPaidMcpServer lets free tool() and paidTool()
registrations coexist on one MCP server, served over Streamable HTTP (an HTTP
request can carry PAYMENT-SIGNATURE; stdio cannot). Each tools/call for a
paid tool runs the same charge middleware:
const mcp = createPaidMcpServer({ charge: { network: "base", recipient: { evm: env.PAYOUT } } });
mcp.paidTool({ name: "premium_report", description: "the paid report", inputSchema: { properties: {}, type: "object" }, price: "$0.05" }, async () =>
text(await buildReport()),
);
export default { fetch: mcp.fetchHandler };fetchHandler takes the Worker's (request, env, ctx) triple, so mounting it as
the default export is what wires ctx.waitUntil through to the charge
middleware — see Receipts below.
A paid call is only charged once the MCP transport would accept it. A request the
transport rejects — an Accept header without text/event-stream, a non-JSON
Content-Type, a malformed or id-less JSON-RPC message, an unsupported
mcp-protocol-version — gets the transport's own error back before the paywall
runs, and nothing is verified or settled. Past that point settlement precedes the
tool: a tool that throws after payment returns an isError result, and the
payment is not refunded automatically (settlement is final on-chain). A refund the
app decides it owes is paid out of band, keyed on the transaction in the receipt.
Receipts
onReceipt is an opt-in, one-way telemetry sink fired once per settled
payment: best-effort, never blocking the paid response. Use it (with
toPaymentEventRow) to mirror x402 revenue into a durable table or
@lunora/payment's events table so it surfaces in Studio. On Workers, the
request's ctx.waitUntil has to reach the gate's deps.waitUntil so the sink is
registered with it and survives past the response; otherwise workerd cancels the
in-flight sink promise at isolate teardown once the response returns, before an
async sink (e.g. a database insert) resolves. Every rail wires this for you as
long as it can see the execution context — withX402 reads it off the action
ctx, @lunora/runtime's x402Charge seam forwards the request's, and
createPaidMcpServer's fetchHandler takes it as its third argument, so mount
it as export default { fetch: mcp.fetchHandler } rather than calling it with a
bare Request.
The pay rail: buy a resource
The pay rail spends real money autonomously, so it is ActionCtx-only (the
only ctx with outbound network + secret access) and fail-closed: the
policy field is required, and an unbounded policy is refused at runtime
before any signer is resolved. Codegen wires ctx.x402 (a lazily-built,
per-ctx rail) when the app configures the x402 capability; the direct API is:
import { createX402Pay } from "@lunora/x402/pay";
const pay = await createX402Pay(
{
network: "base",
signer: { type: "raw-key", secretName: "AGENT_WALLET_KEY" },
policy: { maxPerCall: "$0.10", maxPerRun: "$5.00" },
},
{ getSecret: (name) => ctx.secrets.get(name) },
);
const res = await pay.fetch("https://api.example/paid-report");The returned fetch answers 402 challenges transparently, signing and
retrying within the policy.
Wallet custody
The signer config selects who holds the key, with three shapes:
{ type: "raw-key", secretName }: a self-custodied 32-byte private key read fromctx.secrets(viem on EVM, a@solana/kitkeypair on Solana). Works on EVM and Solana. Simplest.{ type: "cdp", account }: a Coinbase-managed CDP server wallet via the optional@coinbase/cdp-sdkpeer. The SDK gets-or-creates the named account and signs the EIP-712 authorization, so the key never leaves Coinbase. The three CDP credentials are read fromctx.secrets(CDP_API_KEY_ID/CDP_API_KEY_SECRET/CDP_WALLET_SECRETby default, each overridable). EVM only today: CDP-managed Solana custody throwsNOT_IMPLEMENTED, because a CDP Solana account is not a@solana/kitsigner. Build a@solana/kitsigner around your CDP account and pass it via the"signer"escape hatch, or use"raw-key".{ type: "signer", signer }: the escape hatch. Hand in a signer you built yourself. Any custody provider (Turnkey, Privy, Fireblocks, an AWS/GCP KMStoAccount, CDP's viem adapter, …) works once adapted to the structuralClientEvmSigner(EVM) orClientSvmSigner(Solana) shape;@lunora/x402takes no dependency on any provider's SDK, and no secret is read.
@coinbase/x402is a facilitator-auth helper, not a custody provider; first-party Coinbase custody is@coinbase/cdp-sdk.
The spend policy
SpendPolicy is the security seam that keeps an autonomous wallet from
overspending. At least one bound must be set or the rail refuses to build:
| Field | Meaning |
|---|---|
maxPerCall | Hard ceiling on a single payment, in USD. |
maxPerRun | Hard ceiling on cumulative spend by one rail: for ctx.x402, one function invocation (see below). |
allowedAssets | Which stablecoins may be paid, each with its own decimals. Defaults to canonical USDC per friendly network. |
allowedRecipients | Only these payTo addresses may be paid. |
allowedNetworks | Only these networks may be paid on. |
onPaymentRequired | Async approval gate called with the selected requirement before signing; return false to refuse. Use for human-in-the-loop. |
maxPerRun is per invocation, not per wallet. The ledger lives in memory on the
rail, and codegen builds ctx.x402 once per function call, so every action
invocation starts from zero. Nothing in the policy caps what a wallet spends
across invocations or over time. For that budget, keep the running total in
durable state (a Durable Object or a table) and check and debit it in
onPaymentRequired, which runs before every signature.
A request that already carries a PAYMENT-SIGNATURE (or X-PAYMENT) header is
treated as paid by the caller: it is sent as-is, a 402 comes back to you
unanswered, and nothing is reserved against maxPerRun.
Only maxPerCall, maxPerRun, or onPaymentRequired count as a bound.
allowedRecipients / allowedNetworks / allowedAssets narrow where money can
go but cap no amount, so a policy carrying only allowlists is refused.
Enforcement is fail-closed at every step. Requirement selection drops every offer that isn't in an allowed asset, over the per-call cap, or off the allowlists; if nothing survives, nothing is signed. A pre-signature guard then enforces the stateful per-run cap and the confirmation gate, reserving the amount the moment the check passes so concurrent paid fetches can't each pass against the same running total; a failed signature releases the reservation. USD amounts are converted to atomic units digit-by-digit, so no float drift can round a cap the wrong way.
Why the asset matters. A 402 server names the token contract the payment
transfers, and amount is in that token's atomic base units. So a USD cap only
means something once the asset is pinned: 10000 units is a cent of USDC, but the
same 10000 units of an 8-decimal, six-figure-a-coin token is roughly $10. The
policy therefore refuses any asset it wasn't told about, and scales the caps by
that asset's declared decimals rather than assuming six:
policy: {
maxPerCall: "$0.10",
maxPerRun: "$5.00",
// Omit to accept canonical USDC on the networks with a friendly alias.
allowedAssets: [{ asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", decimals: 6, network: "base" }],
}Every entry must be dollar-pegged: the caps are USD and the per-run ledger sums atomic units, so a non-$1 asset mis-prices both. One run also stays locked to a single decimal precision: paying USDC on Base and USDC on Solana under one cap is fine (both 6-decimal), but mixing a 6- and an 18-decimal asset is refused rather than summed into a meaningless total.
The default list covers the networks with a friendly alias except Ethereum
mainnet (eip155:1), which the underlying SDK ships no default stablecoin for.
Paying there needs an explicit allowedAssets entry; without one every
requirement is filtered out, which looks like a silent failure to pay.
Safety notes
- The pay rail is deliberately unavailable on queries and mutations; only actions can spend.
- A failed rail build (e.g. an unbounded policy) is memoised, so a misconfigured wallet stays deterministically closed instead of retrying into a signature.
- Prices are USD-denominated decimals (
"0.01","$0.01", or0.01); exponential notation is rejected.
Platform support
The x402 protocol needs no host binding: both rails are fetch calls to a
facilitator. The generated ctx.x402, though, reads its wallet key through
ctx.secrets, so codegen gates it on the secrets rating of the capability
matrix (PlatformCapabilities in @lunora/platform):
| Target | Charge rail | ctx.x402 (codegen-wired pay rail) | createX402Pay called directly |
|---|---|---|---|
cloudflare | supported | emitted; the wallet key is read from the Secrets Store | supported, any custody |
node | supported | withheld, with a platform_unsupported_feature diagnostic (secrets is unsupported) | supported: { type: "signer" } anywhere, or pass your own getSecret |
celld | supported | withheld, as on node | as on node |
On every target the onReceipt sink only outlives the response where the host
hands the gate a waitUntil (Workers' ctx.waitUntil); without one the sink
promise floats, as described under Receipts.
What the workerd suite verifies: the HTTP-action, procedure and Solana gates
challenge in a real worker, and the pay rail answers that challenge with a viem
EIP-712 signature made inside workerd, through verify and settle, with the
facilitator faked at the fetch boundary. A failed settlement withholds the
resource, an over-cap price is never signed, the per-run cap holds across calls
on one lazyX402Pay rail, and a request that already carries a payment reserves
nothing. Not verified there: a Solana payment end to end (it needs a
recent blockhash from a Solana RPC), and any real chain or facilitator.
Errors
Every error this package throws itself is a LunoraError with a stable code;
what passes through from the x402 SDKs is listed after the table. The
ENV_INVALID ones are internal: the client sees a redacted 500 and the message
lands in the Worker logs.
| Code | When | What to do |
|---|---|---|
ENV_INVALID | Unknown x402 network "…": the network is neither a friendly name nor a CAIP-2 id. | Use one of the friendly names (base, base-sepolia, polygon, arbitrum, …) or a raw namespace:reference id. |
ENV_INVALID | x402 charge on "…" needs recipient.evm (or .svm) set: the payout address for the network's family is missing. | Set recipient.evm for an EVM network, recipient.svm for Solana. |
ENV_INVALID | x402 pay: secret "…" is not set: the wallet (or a CDP credential) secret is empty. | Create the secret (wrangler secret put / Secrets Store), or point secretName at the one you have. |
ENV_INVALID | The wallet key is malformed: not 32-byte hex (EVM), or not a base58 / JSON-array key of 32 or 64 bytes (Solana). | Store the raw private key itself: 64 hex chars for EVM, the solana-keygen JSON array or a base58 secret for Solana. |
ENV_INVALID | A { type: "signer" } signer's address belongs to the other family (a 0x… signer on Solana, or the reverse). | Pass a signer for the configured network's family, or change network. |
ENV_INVALID | An optional peer is missing (@x402/evm + viem, @x402/svm + @solana/kit, @coinbase/cdp-sdk); the message names it and the alternative custody. | Install the named peer. A peer that is installed but fails to load rethrows its own error instead. |
ENV_INVALID | The facilitator answered /supported but does not settle the configured scheme on the configured network (the charge rail's first request). | Point facilitator.url at one that supports the network, or change network. |
X402_FACILITATOR_ERROR | The facilitator could not be reached at initialisation, or answered /verify or /settle with something that is not a valid response, or timed out (502). The handler did not run, except on the settle-after rail, where the resource is withheld. | Check the facilitator's status. A request that failed this way can be retried; one whose settlement timed out may have settled, so key retries on idempotency. |
NOT_IMPLEMENTED | { type: "cdp" } custody on a Solana network. | Wrap your CDP Solana account in a @solana/kit signer and pass it as { type: "signer" }, or use raw-key. |
FORBIDDEN | refusing to build a wallet with an unbounded spend policy. | Set at least one of maxPerCall, maxPerRun or onPaymentRequired. Allowlists alone do not bound spend. |
BAD_REQUEST | The policy cannot be enforced: a malformed USD amount, an empty allowedAssets, a negative or fractional decimals, or one asset listed twice with different decimals. | Fix the policy as the message says. These fire when the rail is built, before any key is read. |
What passes through unwrapped:
- Pay-rail refusals at payment time are plain
Errors from@x402/fetch, notLunoraErrors. An offer overmaxPerCall, in an unlisted asset, or to an unlisted recipient is filtered out (All payment requirements were filtered out by policies); a payment pastmaxPerRunor declined byonPaymentRequiredis aborted with the policy's reason (for examplex402 policy: this payment (…) would exceed the per-run cap). Either way nothing is signed. - Network failures of your own
fetch(the resource server unreachable) reject as they would without the pay rail.
On the charge side, a payment the facilitator refuses goes back to the client as
a 402 with a fresh PAYMENT-REQUIRED challenge: no payment, a payment that
fails verification, and (on the settle-first rails) a settlement the facilitator
declines. A facilitator that fails rather than declines is the
X402_FACILITATOR_ERROR above. The handler runs in none of these cases, except
that settle-after runs it before settling.