X402,一个静态博客变现练习
X402, a static blog monetization excercise

原始链接: https://shtein.me/posts/x402-poc/

Cloudflare 的 Monetization Gateway (x402) 有望通过实现内容小额支付来彻底改变互联网,从而可能取代传统的广告模式。尽管目前仍处于测试阶段,但作者已成功利用 Cloudflare Workers、TypeScript 和 Hono 框架为博客实现了一套自定义的“按请求付费”系统。 该实验涉及创建一个中间件,用于拦截请求并通过兼容 x402 的资源服务器强制执行支付。当用户请求付费内容(例如一个笑话)时,服务器会返回一个 `402 Payment Required` 响应头,触发基于浏览器的 UI 界面,引导用户通过 MetaMask (EVM) 或 Solana 进行支付。一旦支付签名验证通过,Worker 就会提供所请求的内容。 作者总结认为,x402 是一种可行且低成本的替代方案,可以取代“Buy Me a Coffee”等平台,使创作者能够直接从博客或 API 中获利,而无需投放侵入性广告。虽然该设置需要一些基础的服务器端计算,但集成过程非常简单,证明了现代网络正日益为实现无缝、细粒度的内容变现做好准备。

Sorry.
相关文章

原文

x402

Cloudflare has published an announcement couple of days ago about Monetization Gateway launch. This is revolutionary for many reasons. It removes the high entry barrier for monetization, it lets you have a very precise control over what you charge for and how low the payment can be. This literally can change how we use internet making advertisement economy internet runs on obsolete.

It sounds like an ideal tool to monetize a blog, or an API, for example. And even though I don't have anything to monetize, the technology seems too important for me not to try it out. So let's do just that.

The plan

Unfortunately the Cloudflare service still seems to be in a closed beta(?), meaning you can't get access to it freely as of now, you need to join a waitlist. So what we are left with is to implement the thing ourselves. Which is even more fun, isn't it? Fortunately it sounds like a pretty simple set up. That does add a couple of moving parts to a static blog, though nothing that you can't bolt on top in a day.

So what do we need for the thing to work?

  1. A wallet to receive funds to
  2. A worker to execute the payment processing logic
  3. A resource we want to paywall

I have items 1 and 3, but not 2. In this role for now I will go with Cloudflare Workers, since I host my site there. This also dictates the choice of a language we will implement the thing on, TypeScript. Maybe later I will reimplement it to be hosted on my local server, but to keep the scope of the experiment manageable - let's stick with cloudflare for now.

The middleware

So let's jump right in. The first thing we need is a middleware that will receive the request, and make sure the access is granted only on payment as well as handling the price disclosure. The official docs have an example of how to implement it in Hono, so we'll use that. Making a couple of modifications to accommodate for the fact that the code will run on a cloudflare worker rather than a standalone server, putting our wallets, and replacing the example endpoint with a much more useful one - we get this:

import { Hono } from "hono";
import type { MiddlewareHandler } from "hono";
import { paymentMiddleware, x402ResourceServer } from "@x402/hono";
import { ExactEvmScheme } from "@x402/evm/exact/server";
import { ExactSvmScheme } from "@x402/svm/exact/server";
import { HTTPFacilitatorClient } from "@x402/core/server";

const app = new Hono();
const evmAddress = "0xD040AEACCdFf083C5D4cB221F1533e8719a84F0e";
const svmAddress = "HjexCvNzgxJT3Ni7rb98WF4pQGEX2fN7G6Zh26B19mBc";

const facilitatorClient = new HTTPFacilitatorClient({
  url: "https://x402.org/facilitator",
});

let payment: MiddlewareHandler | undefined;

app.use(async (c, next) => {
  payment ??= paymentMiddleware(
    {
      "GET /api/joke": {
        accepts: [
          {
            scheme: "exact",
            price: "$0.01",
            network: "eip155:84532",
            payTo: evmAddress,
          },
          {
            scheme: "exact",
            price: "$0.01",
            network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
            payTo: svmAddress,
          },
        ],
        description: "A premium, hand-picked joke",
        mimeType: "application/json",
      },
    },
    new x402ResourceServer(facilitatorClient)
      .register("eip155:84532", new ExactEvmScheme())
      .register("solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", new ExactSvmScheme()),
  );
  return payment(c, next);
});

const jokes = [];

app.get("/api/joke", (c) => {
  return c.json({
    joke: jokes[Math.floor(Math.random() * jokes.length)],
  });
});

export default app;
The worker also needs a wrangler.jsonc (cloudflare specifics)
{
  "name": "x402-poc",
  "main": "src/index.ts",
  "compatibility_date": "2026-07-01",
  "compatibility_flags": ["nodejs_compat"],
  "env": {
    "production": {
      "name": "x402-poc",
      "routes": [
        { "pattern": "shtein.me/api/*", "zone_name": "shtein.me" }
      ]
    }
  }
}

Two non-obvious bits in there: nodejs_compat is required because the x402 packages import Node built-ins (events, crypto, url) and the build fails without it. And the route lives only in the production environment on purpose: with a route defined, wrangler dev emulates the route's host, so the URLs the middleware generates would point at the real domain instead of localhost.

Running it locally:

npx wrangler dev

And sending a test request - we see the paywall:

curl -i http://localhost:8787/api/joke
HTTP/1.1 402 Payment Required
Content-Length: 2
Content-Type: application/json
PAYMENT-REQUIRED: eyJ4NDAyVmVyc2lvbiI6MiwiZXJyb3IiOiJQYXltZW50IHJlcXVpcmVkIiwicmVzb3VyY2UiOnsidXJsIjoiaHR0cDovL2xvY2FsaG9zdDo4Nzg3L2FwaS9qb2tlIiwiZGVzY3JpcHRpb24iOiJBIHByZW1pdW0sIGhhbmQtcGlja2VkIGpva2UiLCJtaW1lVHlwZSI6ImFwcGxpY2F0aW9uL2pzb24ifSwiYWNjZXB0cyI6W3sic2NoZW1lIjoiZXhhY3QiLCJuZXR3b3JrIjoiZWlwMTU1Ojg0NTMyIiwiYW1vdW50IjoiMTAwMDAiLCJhc3NldCI6IjB4MDM2Q2JENTM4NDJjNTQyNjYzNGU3OTI5NTQxZUMyMzE4ZjNkQ0Y3ZSIsInBheVRvIjoiMHhEMDQwQUVBQ0NkRmYwODNDNUQ0Y0IyMjFGMTUzM2U4NzE5YTg0RjBlIiwibWF4VGltZW91dFNlY29uZHMiOjMwMCwiZXh0cmEiOnsibmFtZSI6IlVTREMiLCJ2ZXJzaW9uIjoiMiJ9fSx7InNjaGVtZSI6ImV4YWN0IiwibmV0d29yayI6InNvbGFuYTpFdFdUUkFCWmFZcTZpTWZlWUtvdVJ1MTY2VlUyeHFhMSIsImFtb3VudCI6IjEwMDAwIiwiYXNzZXQiOiI0ek1NQzlzcnQ1Umk1WDE0R0FnWGhhSGlpM0duUEFFRVJZUEpnWkpEbmNEVSIsInBheVRvIjoiSGpleEN2TnpneEpUM05pN3JiOThXRjRwUUdFWDJmTjdHNlpoMjZCMTltQmMiLCJtYXhUaW1lb3V0U2Vjb25kcyI6MzAwLCJleHRyYSI6eyJmZWVQYXllciI6IkNLUEtKV05kSkVxYTgxeDdDa1oxNEJWUGlZNnkxNlN4czdvd3pucXRXWXA1In19XX0=

The PAYMENT-REQUIRED header is a base64 encoded list of accepted payment methods and prices:

{
  "x402Version": 2,
  "error": "Payment required",
  "resource": {
    "url": "http://localhost:8787/api/joke",
    "description": "A premium, hand-picked joke",
    "mimeType": "application/json"
  },
  "accepts": [
    {
      "scheme": "exact",
      "network": "eip155:84532",
      "amount": "10000",
      "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
      "payTo": "0xD040AEACCdFf083C5D4cB221F1533e8719a84F0e",
      "maxTimeoutSeconds": 300,
      "extra": {
        "name": "USDC",
        "version": "2"
      }
    },
    {
      "scheme": "exact",
      "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
      "amount": "10000",
      "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
      "payTo": "HjexCvNzgxJT3Ni7rb98WF4pQGEX2fN7G6Zh26B19mBc",
      "maxTimeoutSeconds": 300,
      "extra": {
        "feePayer": "CKPKJWNdJEqa81x7CkZ14BVPiY6y16Sxs7owznqtWYp5"
      }
    }
  ]
}

And that is exactly what we want to see, 2 testnets with an exact amount of 1 cent. Let's try to pay now, shall we? In order to test it in this form - we'd need a programmatic client that could make a payment and resend a request with a confirmation. In this particular usecase though I want to see if an end user from a browser can do that. And luckily they can! If you visit the same URL from a browser now - you will see this:

browser-failed-paywall.png

which tells us in plain English what to do to make this work, so let's do it:

npm install @x402/paywall
 import { HTTPFacilitatorClient } from "@x402/core/server";
+import { createPaywall, evmPaywall, svmPaywall } from "@x402/paywall";
@@
     new x402ResourceServer(facilitatorClient)
       .register("eip155:84532", new ExactEvmScheme())
       .register("solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", new ExactSvmScheme()),
+    { appName: "Igor's corner", testnet: true },
+    createPaywall().withNetwork(evmPaywall).withNetwork(svmPaywall).build(),
   );
   return payment(c, next);
 });

And trying to access it now we see a nice UI helping us pay for access:

browser-success-paywall.png

The top up link is there because we are currently on the testnet. In production we won't have that. Let's try and pay with MetaMask.

Connect MetaMask first:

mm-connected.png

Now if we continue to the actual payment:

signature-request.png

And at the end we finally get the content we paid for:

success.png

Couple observations about this final request:

The client sends a signature confirming the payment in PAYMENT-SIGNATURE header which decodes into

{
  "x402Version": 2,
  "payload": {
    "authorization": {
      "from": "0xAF40224e2B8fF2B4840c99d8458A0Fd803b15a0e",
      "to": "0xD040AEACCdFf083C5D4cB221F1533e8719a84F0e",
      "value": "10000",
      "validAfter": "1783263532",
      "validBefore": "1783264432",
      "nonce": "0x82806d883f9d46e376b9dc9c065560521f1c9064cf58d1d4ea53e9e3be5f01df"
    },
    "signature": "0x7284c4c0da5cd029fff1f07fd4e5ff5b5c51bf13064b9a50e787f83f6e67555a419b85db77f27cb47a2101e03c38b93d1af9e6e4a3b2712675f35c975ea284d51c"
  },
  "resource": {
    "url": "http://localhost:8787/api/joke",
    "description": "A premium, hand-picked joke",
    "mimeType": "application/json"
  },
  "accepted": {
    "scheme": "exact",
    "network": "eip155:84532",
    "amount": "10000",
    "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
    "payTo": "0xD040AEACCdFf083C5D4cB221F1533e8719a84F0e",
    "maxTimeoutSeconds": 300,
    "extra": {
      "name": "USDC",
      "version": "2"
    }
  }
}

As you can see it contains all the information our middleware needs to make sure the payment was conducted. So it responds with a success PAYMENT-RESPONSE header:

{
  "success": true,
  "payer": "0xAF40224e2B8fF2B4840c99d8458A0Fd803b15a0e",
  "transaction": "0x88aefe88050c4faaa650383460451ba1faebe3bdc398793970592e6a4ae787dc",
  "network": "eip155:84532"
}

And a response body with the actual content.

The other interesting part is that the browser opened the paid content in another URL, a URL of an in memory object created from the response. I first thought it is a nice UI to prevent double spending on refresh, but looks like no: the paywall fetches the content behind the scenes, and since our response is JSON rather than HTML it has nothing to render in place, so it navigates to an in-memory object URL created from the response. The protocol itself is pay per use, so every time you hit an API - you will be charged.

Closing the loop

Ok, now when we see everything working, let's switch to the production networks and put it all out:

 const facilitatorClient = new HTTPFacilitatorClient({
-  url: "https://x402.org/facilitator"
+  url: "https://facilitator.payai.network"
 });
@@
-            network: "eip155:84532",
+            network: "eip155:8453",
@@
-            network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
+            network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
@@
     new x402ResourceServer(facilitatorClient)
-      .register("eip155:84532", new ExactEvmScheme())
-      .register("solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", new ExactSvmScheme()),
-    { appName: "Igor's corner", testnet: true },
+      .register("eip155:8453", new ExactEvmScheme())
+      .register("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", new ExactSvmScheme()),
+    { appName: "Igor's corner", testnet: false },

The x402.org facilitator only settles testnets, so for the real networks we point at Dexter PayAI — a free facilitator that settles Base and Solana mainnet with no sms or registration account or API keys. Dexter supposed to do the same, but the transactions announced by it never reached the actual network resulting in a payment timeout, with PayAI everything works like a charm.

And that's it. You can press the button below to try it yourself. Don't forget to top up your wallet on base or solana network with USDC before doing that.

🎩 Get a premium joke ($0.01)

Summary

x402 is good not only for paywalling AI agents, but is pretty much ready to help you monetize your blog. No need to fiddle with services like 'Buy Me a Coffee' or annoy your users with ads.

It is easily integratable even into a static site, though you will need some compute. Not much though.

And there is no out of the box solution to make a multinetwork payment for dynamic content that will embed itself into DOM of your page. At least I haven't found one during this short excursion. But the browsers seem to be ready for pay-walling the pages, making it a very easy thing to implement.

联系我们 contact @ memedata.com