The @emdash-cms/x402 package adds x402 payment protocol 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
pnpm
pnpm add @emdash-cms/x402 npm
npm install @emdash-cms/x402 yarn
yarn add @emdash-cms/x402 Setup
Add the integration to your Astro config:
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:
/// <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:
---
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”:
---
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)
---
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:
---
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:
---
const { x402 } = Astro.locals;
const hasPaid = x402.hasPayment(Astro.request);
---
{hasPaid ? (
<p>Full premium content here.</p>
) : (
<p>Subscribe for the full article.</p>
)}
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 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:
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:
pnpm add @x402/svm
x402({
payTo: "YourSolanaAddress",
network: "solana:mainnet",
svm: true,
evm: false, // Disable EVM if only using Solana
});
How It Works
- The
x402()integration registers middleware that creates an enforcer and places it onAstro.locals.x402 - Configuration is passed to the middleware via a Vite virtual module (
virtual:x402/config) - When
enforce()is called, it checks for apayment-signatureheader on the request - If no payment header is present, a
402 Payment Requiredresponse is returned with payment instructions in thePAYMENT-REQUIREDheader - If a payment header is present, it’s verified through the facilitator service and settled
- After settlement,
PAYMENT-RESPONSEheaders are set on the response viaapplyHeaders()
The resource server is initialized lazily on first request and cached for the worker lifetime.