---
title: Charge each request with x402
description: Answer unpaid calls with HTTP 402 and PAYMENT-REQUIRED, settle a PAYMENT-SIGNATURE through a facilitator, and publish the receipt on the request.
section:
    title: Data & background work
    order: 7
order: 7.5
lastUpdated: 2026-10-28
outcomes:
    - "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`](/api/x402) is the
seller side of [x402](https://x402.org/): it builds `PAYMENT-REQUIRED`, verifies and settles a
`PAYMENT-SIGNATURE` through a facilitator, and stamps `PAYMENT-RESPONSE` on the answer.
[`@sdxc/lazy-route`](/api/lazy-route) runs the payment guard before the handler module loads, and
[`@sdxc/result`](/api/result) carries every verify and settle failure.

```bash
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:

```typescript {% title="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`.

> [!WARNING]
> `MemoryFacilitator` accepts any payload whose `accepted` matches the challenge. It never
> touches a chain. Production must use `HTTPFacilitator` pointed at a facilitator that verifies
> and settles on the network you advertise.

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

```typescript {% title="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:

```typescript {% title="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:

```typescript {% title="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);
}
```

```typescript {% title="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:

```typescript {% title="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](/docs/operations-and-testing/mcp-server) declares.

> [!NOTE]
> Clients that speak MCP still need an x402-aware wallet or bridge to settle the 402. Discovery
> (`tools/list`, `resources/list`) stays unpaid so an agent can learn what you sell before it
> pays.

## 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`:

```typescript {% title="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](/docs/data-and-background-work/billing) — subscriptions and feature
  gates when the price is a plan, not a single call.
- [Expose your app over MCP](/docs/operations-and-testing/mcp-server) — the tool declarations
  and route the MCP payment guard sits on.
- [`@sdxc/x402`](/api/x402) — every export, the header codecs, and the Patterns for catalog
  pricing and Vitest helpers.
