Charge each request with x402
Answer unpaid calls with HTTP 402 and PAYMENT-REQUIRED, settle a PAYMENT-SIGNATURE through a facilitator, and publish the receipt on the request.
Last updated
After reading this guide, you will know
- How to publish an x402 seller on a Remix router and guard a priced route
- How to price many operations from a catalog, including MCP
tools/call - How to settle payments in Vitest with
@sdxc/x402/testing
Subscriptions charge for access. Agents that call one capability at a time need a price on the
request itself: without payment the server answers 402 with what to pay, the client signs and
retries, and the server settles before returning the resource.
This guide sells a priced POST /v1/encode on a Remix Worker. @sdxc/x402 is the
seller side of x402: it builds PAYMENT-REQUIRED, verifies and settles a
PAYMENT-SIGNATURE through a facilitator, and stamps PAYMENT-RESPONSE on the answer.
@sdxc/lazy-route runs the payment guard before the handler module loads, and
@sdxc/result carries every verify and settle failure.
npm add @sdxc/lazy-route @sdxc/result @sdxc/x402 remixConfigure the seller
One seller per isolate shares the receive wallet, network and facilitator. Point production at
an HTTP facilitator; leave the URL empty, memory, or anything that is not http(s): so local
runs and Vitest settle in memory without a chain:
import type { Facilitator, SellerConfig } from "@sdxc/x402";
import { HTTPFacilitator, MemoryFacilitator } from "@sdxc/x402";
import { env } from "cloudflare:workers";
/** Base Sepolia USDC when `X402_ASSET` is unset locally. */
const DEFAULT_ASSET = "0x036CbD53842c5426634e7929541eC2318f3dCF7e";
/** Base Sepolia CAIP-2 id when `X402_NETWORK` is unset locally. */
const DEFAULT_NETWORK = "eip155:84532";
/** Stand-in receive address for memory settlement. */
const DEFAULT_PAY_TO = "0x00000000000000000000000000000000000000fe";
/** Seller config the router middleware builds once per isolate. */
export function sellerConfig(): SellerConfig {
let facilitatorUrl = readOptional("X402_FACILITATOR_URL");
let facilitator: Facilitator = isHttpFacilitatorUrl(facilitatorUrl)
? new HTTPFacilitator({ url: facilitatorUrl })
: new MemoryFacilitator();
return {
facilitator,
payTo: readOptional("X402_PAY_TO") ?? DEFAULT_PAY_TO,
network: readOptional("X402_NETWORK") ?? DEFAULT_NETWORK,
asset: readOptional("X402_ASSET") ?? DEFAULT_ASSET,
serviceName: "shop",
};
}
/** True when the configured facilitator is a real HTTP endpoint. */
function isHttpFacilitatorUrl(url: string | null): url is string {
if (url === null || url === "" || url === "memory") return false;
return /^https?:\/\//i.test(url);
}
/** Reads a secret that may be unset under Vitest's `test-<KEY>` stubs. */
function readOptional(name: keyof Cloudflare.Env): string | null {
try {
let value = env[name];
if (typeof value !== "string") return null;
let trimmed = value.trim();
return trimmed === "" ? null : trimmed;
} catch {
return null;
}
}Declare X402_FACILITATOR_URL, X402_PAY_TO, X402_NETWORK and X402_ASSET as Worker secrets
for production. Amounts later in the guide are atomic token units as decimal strings: with
USDC (6 decimals), "1000" is $0.001.
Publish it on the router
x402() builds the seller and publishes it as ctx.x402. Put it on the global chain so every
priced route shares one wallet:
import { lazy } from "@sdxc/lazy-route";
import { requirePayment, x402 } from "@sdxc/x402/middleware";
import { createRouter } from "remix/router";
import { sellerConfig } from "~/app/initializers/x402";
import routes from "~/routes/api";
export function createWebRouter() {
let router = createRouter({
middleware: [x402(sellerConfig())],
});
router.map(
routes.api.encode,
lazy(
() => import("~/app/http/controllers/api/encode"),
[requirePayment({ amount: "1000", description: "Encode a QR symbol" })],
),
);
return router;
}requirePayment sits in lazy()'s second argument so an unpaid call answers 402 without
importing the controller. On success it sets ctx.payment and attaches PAYMENT-RESPONSE to
the handler's response.
Answer the priced handler
The handler only runs after settle. Read ctx.payment when you want the transaction id on the
body or in a log:
import { createAction } from "remix/router";
import routes from "~/routes/api";
export default createAction(routes.api.encode, async (ctx) => {
let body = (await ctx.request.json()) as { text: string };
return Response.json({
payment: {
transaction: ctx.payment?.transaction,
network: ctx.payment?.network,
amount: ctx.payment?.amount,
},
result: { text: body.text, encoded: true },
});
});Declare the route beside your other API routes, for example
encode: post("/v1/encode") in routes/api.ts.
Price many operations from a catalog
One route that sells many operations looks up the amount per request, then runs
requirePayment with that row:
/** One sellable operation: id, price and what the 402 advertises. */
export interface Operation {
id: string;
amount: string;
description: string;
}
const OPERATIONS: Operation[] = [
{
id: "qr.encode",
amount: "1000",
description: "Encode a QR symbol",
},
{
id: "semver.satisfies",
amount: "500",
description: "Ask whether a version satisfies a range",
},
];
/** Finds a catalog row by id, or `undefined` when the id is unknown. */
export function findOperation(id: string): Operation | undefined {
return OPERATIONS.find((operation) => operation.id === id);
}import type { Middleware } from "remix/router";
import { requirePayment } from "@sdxc/x402/middleware";
import { findOperation } from "~/config/catalog";
/**
* Answers 404 for an unknown id, otherwise collects payment for that catalog row.
*/
export let requireCatalogPayment: Middleware = async (ctx, next) => {
let id = ctx.params.id;
if (typeof id !== "string" || id.includes("/")) {
return Response.json({ error: "not_found" }, { status: 404 });
}
let operation = findOperation(id);
if (operation === undefined) {
return Response.json({ error: "not_found", id }, { status: 404 });
}
return requirePayment({
amount: operation.amount,
description: operation.description,
resourceUrl: () => new URL(`/v1/${operation.id}`, ctx.url).href,
})(ctx, next);
};Map a catch-all such as post("/v1/*id") through lazy() with
[requireCatalogPayment] so dotted ids like qr.encode stay one path segment.
Charge an MCP tool call by header
Streamable HTTP MCP names the method and tool in headers so the body stays unread for the MCP
handler. Guard tools/call and leave discovery free:
import type { Middleware } from "remix/router";
import { requirePayment } from "@sdxc/x402/middleware";
import { findOperation } from "~/config/catalog";
/** Header the Streamable HTTP transport uses to name the method. */
const MCP_METHOD = "mcp-method";
/** Header naming the tool on `tools/call`. */
const MCP_NAME = "mcp-name";
/**
* Free MCP methods keep flowing; a priced `tools/call` answers 402 until settled.
*/
export let requireToolPayment: Middleware = async (ctx, next) => {
if (ctx.request.headers.get(MCP_METHOD)?.toLowerCase() !== "tools/call") {
return next();
}
let name = ctx.request.headers.get(MCP_NAME);
if (name === null || name === "") return next();
let operation = findOperation(name);
if (operation === undefined) return next();
return requirePayment({
amount: operation.amount,
description: operation.description,
resourceUrl: () => `mcp://tool/${operation.id}`,
})(ctx, next);
};Mount requireToolPayment on the MCP route's lazy() guards, after the seller middleware is
already on the router. Tool names in the catalog should match the names
Expose your app over MCP declares.
Test a paid route
Challenge once to learn the accept entry the seller advertised, then retry with a memory
signature built from it. That keeps the test aligned when env stubs change payTo or
network:
import type { PaymentRequired } from "@sdxc/x402";
import { isFailure } from "@sdxc/result";
import { PAYMENT_REQUIRED, PAYMENT_RESPONSE, decodeHeader } from "@sdxc/x402";
import { withMemoryPayment } from "@sdxc/x402/testing";
import { expect, test } from "vitest";
import { createWebRouter } from "~/app/initializers/router";
test("answers 402 without payment", async () => {
let response = await createWebRouter().fetch(
new Request("https://api.example/v1/encode", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text: "hello" }),
}),
);
expect(response.status).toBe(402);
expect(response.headers.get(PAYMENT_REQUIRED)).toBeTruthy();
});
test("runs after a memory payment", async () => {
let router = createWebRouter();
let challenge = await router.fetch(
new Request("https://api.example/v1/encode", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text: "hello" }),
}),
);
expect(challenge.status).toBe(402);
let decoded = decodeHeader(challenge.headers.get(PAYMENT_REQUIRED)!);
if (isFailure(decoded)) throw decoded.error;
let accepts = (decoded.data as PaymentRequired).accepts;
let paid = await router.fetch(
new Request("https://api.example/v1/encode", {
method: "POST",
headers: {
"content-type": "application/json",
...withMemoryPayment(accepts[0]!),
},
body: JSON.stringify({ text: "hello" }),
}),
);
expect(paid.status).toBe(200);
expect(paid.headers.get(PAYMENT_RESPONSE)).toBeTruthy();
let body = (await paid.json()) as { result: { encoded: boolean } };
expect(body.result.encoded).toBe(true);
});When the seller's wallet matches the test defaults (TEST_PAY_TO, TEST_NETWORK,
TEST_ASSET), build the signature from testSeller().requirements("1000") instead of
decoding the challenge.
Where to go next
Charge for your app — subscriptions and feature gates when the price is a plan, not a single call.
Expose your app over MCP — the tool declarations and route the MCP payment guard sits on.
@sdxc/x402— every export, the header codecs, and the Patterns for catalog pricing and Vitest helpers.