Skip to content

x402 Payments

The @emdash-cms/x402 package adds x402 payment protocol support to server-rendered Astro sites. It is a standalone Astro integration, so payment enforcement does not require EmDash. When the site also uses EmDash, a content field can supply the price for each entry.

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.

Use normal enforcement when every request to the route must present a valid payment. This mode does not depend on Cloudflare-specific request metadata.

Use bot-only mode when the site runs on Cloudflare Workers with Bot Management enabled and only low-score requests should pay. The package reads request.cf.botManagement.score; when that value is absent, it treats the request as human and skips enforcement. Do not use bot-only mode when missing bot data must fail closed.

hasPayment() provides a third behavior for presentation only. It reports whether the request has a payment header but does not verify or settle the payment.

Install the package with your package manager:

Terminal window
pnpm add @emdash-cms/x402

Choose a wallet, network, and facilitator that work together. The integration supports Ethereum Virtual Machine (EVM) networks by default, but the facilitator must also support the configured network and asset. The following example uses Base mainnet and enforces payment for every request:

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",
}),
],
});

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

Enable botOnly in the integration configuration:

astro.config.mjs
x402({
payTo: "0xYourWalletAddress",
network: "eip155:8453",
defaultPrice: "$0.01",
botOnly: true,
botScoreThreshold: 30,
});

The integration reads Cloudflare’s 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 only when it is absent.
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>

Check for a payment header without enforcing

Section titled “Check for a payment header without enforcing”

Use hasPayment() to check whether a request includes a payment header without verifying or enforcing it. This can change a prompt or sign-in message, but it must not unlock protected content:

---
const { x402 } = Astro.locals;
const hasPaymentHeader = x402.hasPayment(Astro.request);
---
{hasPaymentHeader ? (
<p>Payment supplied. Verification is still required.</p>
) : (
<p>This page requires payment.</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 Ethereum Virtual Machine network support
svmbooleanfalseEnable Solana Virtual Machine 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

CAIP-2 gives blockchain networks unambiguous identifiers such as eip155:8453 for Base mainnet. Use the exact identifier advertised by the facilitator. The package accepts the eip155:* family when EVM support is enabled and the solana:* family when Solana Virtual Machine (SVM) support is enabled; this does not guarantee that a facilitator accepts every network in that family.

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 svm: true, use the CAIP-2 Solana identifier supported by the facilitator, and disable EVM if the site only accepts Solana payments. For example, the Solana mainnet identifier used by @x402/svm 2.8 is solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:

astro.config.mjs
x402({
payTo: "YourSolanaAddress",
network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
svm: true,
evm: false, // Disable EVM if only using Solana
});
  1. The integration puts the payment enforcer on Astro.locals.x402 before the page runs.
  2. enforce() checks for a payment-signature header.
  3. Without a payment header, enforce() returns a 402 Payment Required response whose body and PAYMENT-REQUIRED header describe the accepted payment.
  4. With a matching payment, the configured facilitator verifies and settles it.
  5. enforce() returns the payer and settlement result. Call applyHeaders() so the page response includes the facilitator’s PAYMENT-RESPONSE proof.