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

@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 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 across the wallet's lifetime (the ctx, for ctx.x402).
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.

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.