x402 Payments
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
Section titled “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
Section titled “Installation”pnpm add @emdash-cms/x402npm install @emdash-cms/x402yarn add @emdash-cms/x402Add 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
Section titled “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
Section titled “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
Section titled “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 defaultconst 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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “Solana Support”Solana is opt-in. Install @x402/svm and enable it in config:
pnpm add @x402/svmx402({ payTo: "YourSolanaAddress", network: "solana:mainnet", svm: true, evm: false, // Disable EVM if only using Solana});How It Works
Section titled “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.