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 X-PAYMENT payload is verified (via the facilitator); then the
handler and settlement run, in an order that depends on the rail (see below);
finally X-PAYMENT-RESPONSE is attached to the returned resource.
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 after the handler runs
(the handler's response is passed to settlement as transport context). A
settlement failure on this path cannot undo a handler that already ran, so a
handler gated with withX402 must be idempotent or compensatable if it has
side effects: a retried/duplicate charge attempt (or a settlement failure
after the handler already committed something) must not double-apply that
effect.
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.
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.
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 X-PAYMENT; 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()),
);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, wire
the request's ctx.waitUntil to the gate's deps.waitUntil (where the caller
has one to give, e.g. @lunora/runtime's x402Charge seam) so the sink is
registered with waitUntil 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.
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 across the wallet's lifetime (the ctx, for ctx.x402). |
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. |
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.