Skip to content

x402 Payments

The @emdash-cms/x402 package adds x402 payment protocol support to any Astro site on Cloudflare. It runs as a standalone Astro integration, and pairs with EmDash’s CMS fields for per-page pricing when you use EmDash.

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.

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).

Install the package with your package manager:

Terminal window
pnpm add @emdash-cms/x402

Add the integration to your Astro config:

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:

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

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

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).

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)
---

When using EmDash, add a regular number field to your collection for per-page pricing and read it at request time:

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>

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>
)}
OptionTypeDefaultDescription
payTostringrequiredDestination wallet address
networkstringrequiredCAIP-2 network identifier (e.g., eip155:8453)
defaultPricePriceDefault price, overridable per-page
facilitatorUrlstringhttps://x402.org/facilitatorPayment facilitator URL
schemestring"exact"Payment scheme
maxTimeoutSecondsnumber60Maximum timeout for payment signatures
evmbooleantrueEnable EVM chain support
svmbooleanfalseEnable Solana chain support (requires @x402/svm)
botOnlybooleanfalseOnly enforce payment for bots
botScoreThresholdnumber30Bot score threshold (1-99, lower = more likely bot)

Prices can be specified in several formats:

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

Networks use CAIP-2 format:

NetworkIdentifier
Base mainneteip155:8453
Base Sepoliaeip155:84532
Ethereumeip155:1
Solanasolana:mainnet

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 is opt-in. Install @x402/svm and enable it in config:

Terminal window
pnpm add @x402/svm

Set the network to a Solana identifier and disable EVM if you only use Solana:

astro.config.mjs
x402({
payTo: "YourSolanaAddress",
network: "solana:mainnet",
svm: true,
evm: false, // Disable EVM if only using Solana
});
  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.