---
title: x402 Payments
description: Monetize content with the x402 payment protocol — charge bots, not humans.
---

import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";

The `@emdash-cms/x402` package adds [x402 payment protocol](https://www.x402.org/) support to any Astro site on Cloudflare. It works standalone — no dependency on EmDash core — but pairs well with EmDash's CMS fields for per-page pricing.

x402 is an HTTP-native payment protocol. When a client requests a paid resource without payment, the server responds with `402 Payment Required` and machine-readable payment instructions. Agents and browsers that understand x402 can complete payment automatically and retry the request.

## When to Use This

The most common use case is **bot-only mode**: charge AI agents and scrapers for content access while letting human visitors read for free. This uses Cloudflare Bot Management to distinguish bots from humans.

You can also enforce payment for all visitors, or check for payment headers without enforcing (conditional rendering).

## Installation

<Tabs>
<TabItem label="pnpm">
```bash
pnpm add @emdash-cms/x402
```
</TabItem>
<TabItem label="npm">
```bash
npm install @emdash-cms/x402
```
</TabItem>
<TabItem label="yarn">
```bash
yarn add @emdash-cms/x402
```
</TabItem>
</Tabs>

## Setup

Add the integration to your Astro config:

```js title="astro.config.mjs"
import { defineConfig } from "astro/config";
import { x402 } from "@emdash-cms/x402";

export default defineConfig({
	integrations: [
		x402({
			payTo: "0xYourWalletAddress",
			network: "eip155:8453", // Base mainnet
			defaultPrice: "$0.01",
			botOnly: true,
			botScoreThreshold: 30,
		}),
	],
});
```

Add the type reference so TypeScript knows about `Astro.locals.x402`:

```ts title="src/env.d.ts"
/// <reference types="@emdash-cms/x402/locals" />
```

## Basic Usage

The integration puts an enforcer on `Astro.locals.x402`. Call `enforce()` in your page frontmatter to gate content behind payment:

```astro title="src/pages/posts/[...slug].astro"
---
const { x402 } = Astro.locals;

const result = await x402.enforce(Astro.request, {
  price: "$0.05",
  description: "Premium article",
});

// If the request has no valid payment, enforce() returns a 402 Response.
// Return it directly to send payment instructions to the client.
if (result instanceof Response) return result;

// Payment verified (or skipped in botOnly mode). Apply response headers
// so the client gets settlement proof.
x402.applyHeaders(result, Astro.response);
---

<article>
  <h1>Premium content</h1>
</article>
```

The `enforce()` method returns either:

- A **`Response`** (402) — the client needs to pay. Return it directly.
- An **`EnforceResult`** — the request should proceed. The content was paid for, or enforcement was skipped (human in botOnly mode).

## Bot-Only Mode

When `botOnly` is `true`, the integration reads `request.cf.botManagement.score` to classify requests:

- **Score below threshold** (default 30) -> treated as bot, payment enforced
- **Score at or above threshold** -> treated as human, enforcement skipped
- **No bot management data** (local dev, non-CF deployment) -> treated as human

The `EnforceResult` includes a `skipped` flag so you can distinguish "didn't need to pay" from "paid":

```astro
---
const result = await x402.enforce(Astro.request, { price: "$0.01" });
if (result instanceof Response) return result;

x402.applyHeaders(result, Astro.response);

// result.paid    — true if payment was verified
// result.skipped — true if enforcement was skipped (human in botOnly mode)
// result.payer   — wallet address of payer (if paid)
---
```

<Aside>
	Bot-only mode requires a Cloudflare deployment with Bot Management enabled. In local development,
	all requests are treated as human and enforcement is skipped.
</Aside>

## Per-Page Pricing with EmDash

When using EmDash, you can add a `number` field to your collection for per-page pricing. No special schema or admin UI is needed — just a regular CMS field:

```astro title="src/pages/posts/[...slug].astro"
---
import { getEmDashEntry } from "emdash";

const { slug } = Astro.params;
const { entry } = await getEmDashEntry("posts", slug);

if (!entry) return Astro.redirect("/404");

const { x402 } = Astro.locals;

// Use the price from the CMS, falling back to a default
const result = await x402.enforce(Astro.request, {
  price: entry.data.price || "$0.01",
  description: entry.data.title,
});
if (result instanceof Response) return result;

x402.applyHeaders(result, Astro.response);
---

<article>
  <h1>{entry.data.title}</h1>
</article>
```

## Checking for Payment Without Enforcing

Use `hasPayment()` to check if a request includes payment headers without verifying or enforcing. This is useful for conditional rendering — showing different content to paying vs non-paying visitors:

```astro
---
const { x402 } = Astro.locals;

const hasPaid = x402.hasPayment(Astro.request);
---

{hasPaid ? (
  <p>Full premium content here.</p>
) : (
  <p>Subscribe for the full article.</p>
)}
```

<Aside type="caution">
	`hasPayment()` only checks for the presence of a payment header. It does not verify the payment
	is valid. Use `enforce()` when you need verified payment.
</Aside>

## Configuration Reference

| Option              | Type      | Default                          | Description                                      |
| ------------------- | --------- | -------------------------------- | ------------------------------------------------ |
| `payTo`             | `string`  | required                         | Destination wallet address                        |
| `network`           | `string`  | required                         | CAIP-2 network identifier (e.g., `eip155:8453`)  |
| `defaultPrice`      | `Price`   | —                                | Default price, overridable per-page               |
| `facilitatorUrl`    | `string`  | `https://x402.org/facilitator`   | Payment facilitator URL                           |
| `scheme`            | `string`  | `"exact"`                        | Payment scheme                                    |
| `maxTimeoutSeconds` | `number`  | `60`                             | Maximum timeout for payment signatures            |
| `evm`               | `boolean` | `true`                           | Enable EVM chain support                          |
| `svm`               | `boolean` | `false`                          | Enable Solana chain support (requires `@x402/svm`) |
| `botOnly`           | `boolean` | `false`                          | Only enforce payment for bots                     |
| `botScoreThreshold` | `number`  | `30`                             | Bot score threshold (1-99, lower = more likely bot) |

### Price Format

Prices can be specified in several formats:

- **Dollar string** — `"$0.10"` (the `$` prefix is stripped, value passed as-is)
- **Numeric string** — `"0.10"`
- **Number** — `0.10`
- **Object** — `{ amount: "100000", asset: "0x...", extra: {} }` for explicit asset/amount

### Network Identifiers

Networks use [CAIP-2](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-2.md) format:

| Network       | Identifier       |
| ------------- | ---------------- |
| Base mainnet  | `eip155:8453`    |
| Base Sepolia  | `eip155:84532`   |
| Ethereum      | `eip155:1`       |
| Solana        | `solana:mainnet` |

## Enforce Options

Override config defaults for a specific page:

```ts
await x402.enforce(Astro.request, {
	price: "$0.25", // Override price
	payTo: "0xDifferentWallet", // Override wallet
	network: "eip155:1", // Override network
	description: "Article: How x402 Works", // Resource description
	mimeType: "text/html", // MIME type hint
});
```

## Solana Support

Solana is opt-in. Install `@x402/svm` and enable it in config:

```bash
pnpm add @x402/svm
```

```js title="astro.config.mjs"
x402({
	payTo: "YourSolanaAddress",
	network: "solana:mainnet",
	svm: true,
	evm: false, // Disable EVM if only using Solana
});
```

## How It Works

1. The `x402()` integration registers middleware that creates an enforcer and places it on `Astro.locals.x402`
2. Configuration is passed to the middleware via a Vite virtual module (`virtual:x402/config`)
3. When `enforce()` is called, it checks for a `payment-signature` header on the request
4. If no payment header is present, a `402 Payment Required` response is returned with payment instructions in the `PAYMENT-REQUIRED` header
5. If a payment header is present, it's verified through the facilitator service and settled
6. After settlement, `PAYMENT-RESPONSE` headers are set on the response via `applyHeaders()`

The resource server is initialized lazily on first request and cached for the worker lifetime.
