SearchCtrlK

  • ↑↓navigate
  • ↵open
  • escclose

Can’t find it? Contact us

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.

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.

Terminal
npm add @sdxc/lazy-route @sdxc/result @sdxc/x402 remix

Configure 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:

app/initializers/x402.ts
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:

app/initializers/router.ts
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:

app/http/controllers/api/encode.ts
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:

config/catalog.ts
/** 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);
}
app/http/middleware/require-catalog-payment.ts
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:

app/http/middleware/require-tool-payment.ts
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:

app/http/controllers/api/encode.test.ts
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.

Documentation

Can’t find it? Contact us