Skip to content
DocspackagesDocumentation

@lunora/x402

Agentic payments over the x402 protocol — charge agents per request (charge rail) and let your agents pay 402-gated resources (pay rail).

PackagesX402

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:

SubpathRailWho uses it
@lunora/x402/chargechargeYour deployment sells: gate an HTTP-action route, a procedure, or an MCP tool behind a price.
@lunora/x402/paypayYour 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/x402

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

@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 from ctx.secrets (viem on EVM, a @solana/kit keypair on Solana). Works on EVM and Solana. Simplest.
  • { type: "cdp", account }: a Coinbase-managed CDP server wallet via the optional @coinbase/cdp-sdk peer. 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 from ctx.secrets (CDP_API_KEY_ID / CDP_API_KEY_SECRET / CDP_WALLET_SECRET by default, each overridable). EVM only today: CDP-managed Solana custody throws NOT_IMPLEMENTED, because a CDP Solana account is not a @solana/kit signer. Build a @solana/kit signer 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 KMS toAccount, CDP's viem adapter, …) works once adapted to the structural ClientEvmSigner (EVM) or ClientSvmSigner (Solana) shape; @lunora/x402 takes no dependency on any provider's SDK, and no secret is read.

@coinbase/x402 is 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:

FieldMeaning
maxPerCallHard ceiling on a single payment, in USD.
maxPerRunHard ceiling on cumulative spend by one rail: for ctx.x402, one function invocation (see below).
allowedAssetsWhich stablecoins may be paid, each with its own decimals. Defaults to canonical USDC per friendly network.
allowedRecipientsOnly these payTo addresses may be paid.
allowedNetworksOnly these networks may be paid on.
onPaymentRequiredAsync 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", or 0.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):

TargetCharge railctx.x402 (codegen-wired pay rail)createX402Pay called directly
cloudflaresupportedemitted; the wallet key is read from the Secrets Storesupported, any custody
nodesupportedwithheld, with a platform_unsupported_feature diagnostic (secrets is unsupported)supported: { type: "signer" } anywhere, or pass your own getSecret
celldsupportedwithheld, as on nodeas 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.

CodeWhenWhat to do
ENV_INVALIDUnknown 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_INVALIDx402 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_INVALIDx402 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_INVALIDThe 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_INVALIDA { 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_INVALIDAn 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_INVALIDThe 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_ERRORThe 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.
FORBIDDENrefusing 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_REQUESTThe 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, not LunoraErrors. An offer over maxPerCall, in an unlisted asset, or to an unlisted recipient is filtered out (All payment requirements were filtered out by policies); a payment past maxPerRun or declined by onPaymentRequired is aborted with the policy's reason (for example x402 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.