Skip to content

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.

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

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

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