跳到主内容
快讯直播
AI智模界
教程

给自建搜索 API 接上 x402:让智能体按次自动付费

这篇能做出什么

做完这篇教程,你会得到一个可以直接对外提供服务的搜索/工具 API:调用方不带支付凭证时,服务端返回 HTTP 402 和一份“报价单”;调用方按报价单签名付款后,携带支付凭证重试,服务端向 facilitator 校验、在链上完成结算,然后把搜索结果返回。整个过程没有注册、没有 API Key、没有月结账单,智能体自己带着钱包就能付费调用。

一个具体场景:你写了一个检索工具,每次调用成本大约是几分钱。以前要么免费开放被人刷,要么让用户注册账号发 Key。改成 x402 之后,智能体在第一次请求时就会收到 402,读到“每次 0.01 个稳定币”的报价,签名付款,拿到结果。它甚至不需要在代码里专门处理支付,只要把带钱包的 fetch 封装套在工具函数下面即可。

本文会依次实现:

  • 服务端的 402 挑战与报价单生成
  • 支付载荷的校验、幂等与结算回调
  • 结算回执如何回传给调用方
  • 一个带钱包的智能体客户端,自动完成“被拒 → 付款 → 重试”
  • 本地不花钱的联调方法(自建 mock facilitator)
  • 价格、重放、限额等风控要点

x402 协议的具体字段名、版本号、支持的链与资产,以官方文档当前版本为准。本文的代码结构是按通用流程写的,字段对不上时按官方文档调整即可,整体时序不会变。

前置条件清单

  • Node.js 环境:需要带全局 fetch 的版本,Node 18 及以上通常都可以,具体以官方文档当前版本为准。
  • 一个收款钱包地址:链上地址,注意区分测试网和主网。
  • facilitator 服务:x402 的校验与结算由 facilitator 完成,可以用官方托管的,也可以自建。你需要拿到它的基础地址,并且确认它支持你选的 network 和 asset。
  • 公网 HTTPS 地址:部署在 Nginx / Caddy 后面即可。协议本身不要求 HTTPS,但支付凭证是明文头,生产环境必须上 TLS。
  • 测试网资产:联调阶段用测试网的稳定币,避免真金白银试错。
  • 一个真实的搜索实现:没有的话,先写个返回固定结构的假数据也能跑通全流程。

目录结构大致如下:

```text

x402-search-demo/

├── server.js # 收费的搜索 API

├── mock-facilitator.js # 本地联调用的假 facilitator

├── client-agent.js # 带钱包的智能体客户端

└── .env

```

分步骤

第 1 步:先理解握手时序

x402 本质是把 HTTP 402 这个沉睡了三十年的状态码用起来。一次成功的调用长这样:

```text

客户端 服务端 facilitator

| POST /api/search | |

|----------------------------->| |

| 402 + 报价单(accepts) | |

|<-----------------------------| |

| 本地签名,生成支付载荷 | |

| POST + X-PAYMENT 头 | |

|----------------------------->| /verify 校验签名与金额 |

| |----------------------------->|

| | isValid: true |

| |<-----------------------------|

| | 执行搜索 |

| | /settle 上链结算 |

| |----------------------------->|

| | { success, transaction } |

| |<-----------------------------|

| 200 + 结果 + X-PAYMENT-RESPONSE |

|<-----------------------------| |

```

关键点有两个:校验在干活之前,结算在干活之后。先 verify 可以挡住绝大多数无效请求,不浪费下游算力;后 settle 是因为 verify 只是验签,真正扣钱要等结算成功。

第 2 步:搭起搜索端点(先不计费)

先把业务跑通,后面再加支付层。用 Express 写一个最小端点:

```javascript

// server.js

import express from "express";

const app = express();

app.use(express.json());

// 假的搜索实现,换成你自己的检索逻辑

async function doSearch(query) {

return {

query,

took_ms: 12,

items: [

{ title: ${query} 入门指南, url: "https://example.com/a" },

{ title: ${query} 实践笔记, url: "https://example.com/b" },

],

};

}

app.post("/api/search", async (req, res) => {

const q = String(req.body?.q || "").trim();

if (!q) return res.status(400).json({ error: "q is required" });

res.json(await doSearch(q));

});

app.listen(3000, () => console.log("search api on :3000"));

```

跑起来,curl 一下能返回结果,地基就打好了。

第 3 步:生成报价单

报价单就是 402 响应体里的 accepts 数组,客户端读它才知道该付多少钱、付给谁、付到哪条链。把配置集中到环境变量里:

```bash

.env 示例,值全部以官方文档当前版本为准

X402_PAY_TO=0x你的收款地址

X402_NETWORK=官方文档给出的网络标识

X402_ASSET=结算用的稳定币合约地址

X402_PRICE=10000 # 原子单位。6 位小数的代币里 10000 = 0.01

X402_FACILITATOR=http://127.0.0.1:4021

PUBLIC_BASE_URL=https://api.example.com

```

注意 X402_PRICE 用的是最小单位字符串。稳定币常见 6 位小数,把 0.01 写成 1 就是千分之一的价钱,这个坑后面还会提。

```javascript

// quote.js

export function buildQuote(req, cfg) {

return {

scheme: "exact",

network: cfg.network,

maxAmountRequired: cfg.priceAtomic,

resource: ${cfg.publicBaseUrl}${req.originalUrl},

description: "Search API:一次关键词检索",

mimeType: "application/json",

payTo: cfg.payTo,

maxTimeoutSeconds: 60,

asset: cfg.asset,

extra: { name: "USDC", version: "2" }, // EIP-712 域信息,取值以官方文档为准

};

}

```

resource 必须和客户端实际请求的 URL 完全一致,包括查询串、末尾斜杠、端口和协议。这是联调阶段最高频的失败点,后面排错部分会展开。

第 4 步:下发 402 挑战

用一个中间件拦住没有支付凭证的请求:

```javascript

// middleware.js

import { buildQuote } from "./quote.js";

export function require402(cfg) {

return (req, res, next) => {

const paid = req.header("X-PAYMENT");

if (!paid) {

return res.status(402).json({

x402Version: 1, // 版本号以官方文档当前版本为准

error: "X-PAYMENT header is required",

accepts: [buildQuote(req, cfg)],

});

}

next();

};

}

```

挂上去:

```javascript

app.post("/api/search", require402(cfg), handler);

```

此时 curl -i 请求会看到 402 和完整的报价单,说明挑战层已经通了。

第 5 步:校验并结算

这一步把凭证翻译成钱。先写一个 facilitator 的调用封装:

```javascript

// facilitator.js

export async function callFacilitator(base, pathname, body) {

const resp = await fetch(${base}${pathname}, {

method: "POST",

headers: { "Content-Type": "application/json" },

body: JSON.stringify(body),

signal: AbortSignal.timeout(10_000),

});

if (!resp.ok) {

throw new Error(facilitator ${pathname} responded ${resp.status});

}

return resp.json();

}

```

然后是完整处理器:

```javascript

// handler.js

import { callFacilitator } from "./facilitator.js";

import { buildQuote } from "./quote.js";

const seen = new Map(); // 幂等表:payer + nonce -> 过期时间

function decodePayment(headerValue) {

return JSON.parse(Buffer.from(headerValue, "base64").toString("utf8"));

}

export function paidHandler(cfg, doSearch) {

return async (req, res) => {

const requirements = buildQuote(req, cfg);

let paymentPayload;

try {

paymentPayload = decodePayment(req.header("X-PAYMENT"));

} catch {

return res.status(402).json({

x402Version: 1,

error: "malformed X-PAYMENT header",

accepts: [requirements],

});

}

// 1) 验签与金额校验

const verify = await callFacilitator(cfg.facilitator, "/verify", {

x402Version: 1,

paymentPayload,

paymentRequirements: requirements,

});

if (!verify.isValid) {

return res.status(402).json({

x402Version: 1,

error: verify.invalidReason || "payment invalid",

accepts: [requirements],

});

}

// 2) 幂等:同一笔授权只允许兑付一次

const payer = paymentPayload?.payload?.authorization?.from ?? "unknown";

const nonce = paymentPayload?.payload?.authorization?.nonce ?? "";

const key = ${payer}:${nonce};

if (seen.has(key)) {

return res.status(409).json({ error: "payment already used" });

}

seen.set(key, Date.now());

// 3) 干活

let result;

try {

result = await doSearch(String(req.body?.q || "").trim());

} catch (err) {

return res.status(500).json({ error: "search failed" }); // 未结算,不扣费

}

// 4) 结算

let settle;

try {

settle = await callFacilitator(cfg.facilitator, "/settle", {

x402Version: 1,

paymentPayload,

paymentRequirements: requirements,

});

} catch (err) {

// 结算失败:业务已算完但钱没收到。记录日志,返回结果但标记待补

console.error("settle failed", key, err.message);

return res.status(502).json({ error: "settlement unavailable" });

}

// 5) 回执

res.setHeader(

"X-PAYMENT-RESPONSE",

Buffer.from(JSON.stringify(settle)).toString("base64")

);

res.json(result);

};

}

```

paymentPayload 的结构基本是 { x402Version, scheme, network, payload: { signature, authorization: { from, to, value, validAfter, validBefore, nonce } } },字段名以官方文档当前版本为准。authorization 里那个 nonce 是链上一次性随机数,天然适合做幂等键。

seen 用内存 Map 只够单机单进程用。上生产换成 Redis 并设置过期时间,过期时间对齐授权的 validBefore。

第 6 步:写一个会自动付款的客户端

推荐优先用官方提供的客户端封装库,它会把“收到 402 → 读报价 → 签名 → 带头发起重试”这一整套自动化。包名与导出函数以官方文档为准,形如:

```javascript

// client-agent.js

import { wrapFetchWithPayment } from "x402-fetch";

import { privateKeyToAccount } from "viem/accounts";

const account = privateKeyToAccount(process.env.AGENT_PRIVATE_KEY);

const payFetch = wrapFetchWithPayment(fetch, account);

const resp = await payFetch(${process.env.API_BASE}/api/search, {

method: "POST",

headers: { "Content-Type": "application/json" },

body: JSON.stringify({ q: "x402 协议" }),

});

console.log(resp.status);

console.log(await resp.json());

// 回执里能看到交易哈希和实际付款人

const receipt = resp.headers.get("X-PAYMENT-RESPONSE");

if (receipt) {

console.log(JSON.parse(Buffer.from(receipt, "base64").toString("utf8")));

}

```

如果你想自己实现客户端,骨架大致是这样:

```javascript

// 手写版骨架:只演示顺序,签名细节以官方文档为准

async function payAndFetch(url, options) {

const first = await fetch(url, options);

if (first.status !== 402) return first;

const { accepts } = await first.json();

const requirements = accepts[0];

// 按报价单构造 EIP-3009 授权并签名,得到 paymentPayload

const paymentPayload = await buildPaymentPayload(requirements, account);

return fetch(url, {

...options,

headers: {

...options.headers,

"X-PAYMENT": Buffer.from(JSON.stringify(paymentPayload)).toString("base64"),

},

});

}

```

手写时最容易错的是 extra 里的 EIP-712 域信息(代币名称与版本)。这两个值必须和链上代币合约一致,抄错了签名校验一定失败。

第 7 步:挂到智能体的工具调用上

真正让智能体“自动结算”的关键,是把带钱包的 fetch 放在工具执行层,而不是让模型去思考支付。模型看到的仍然是一个普通工具:

```javascript

// tools.js

const tools = [

{

type: "function",

function: {

name: "web_search",

description: "检索互联网上的公开信息,返回标题和链接",

parameters: {

type: "object",

properties: { q: { type: "string", description: "检索关键词" } },

required: ["q"],

},

},

},

];

async function runTool(name, args) {

if (name !== "web_search") throw new Error(unknown tool: ${name});

const resp = await payFetch(${process.env.API_BASE}/api/search, {

method: "POST",

headers: { "Content-Type": "application/json" },

body: JSON.stringify({ q: args.q }),

});

if (!resp.ok) throw new Error(search failed: ${resp.status});

return await resp.json();

}

```

这样模型完全不用理解 402,付款发生在 payFetch 内部。代价是每次工具调用都会真实花钱,所以客户端侧也要设预算:

```javascript

// 简单的预算护栏:超过上限就抛错,不再发起请求

let spent = 0;

const BUDGET = Number(process.env.AGENT_BUDGET || "1");

async function guardedFetch(url, options) {

if (spent >= BUDGET) throw new Error("agent budget exhausted");

const resp = await payFetch(url, options);

if (resp.ok) spent += Number(process.env.UNIT_PRICE || "0.01");

return resp;

}

```

第 8 步:本地不花钱联调

正式接 facilitator 之前,先用一个假的跑通全流程。它不碰链,只回固定结构:

```javascript

// mock-facilitator.js

import express from "express";

const app = express();

app.use(express.json());

app.post("/verify", (req, res) => {

const { paymentPayload } = req.body;

const sig = paymentPayload?.payload?.signature;

if (!sig) {

return res.json({ isValid: false, invalidReason: "missing signature" });

}

res.json({ isValid: true });

});

app.post("/settle", (req, res) => {

const { paymentPayload, paymentRequirements } = req.body;

res.json({

success: true,

transaction: "0x" + "0".repeat(64),

network: paymentRequirements.network,

payer: paymentPayload?.payload?.authorization?.from ?? "0x0",

});

});

app.listen(4021, () => console.log("mock facilitator on :4021"));

```

两个进程一起起:

```bash

node mock-facilitator.js &

X402_FACILITATOR=http://127.0.0.1:4021 node server.js

```

再手动造一个假的 X-PAYMENT 头打过去:

```bash

PAYLOAD=$(printf '%s' '{"x402Version":1,"payload":{"signature":"0xdead","authorization":{"from":"0xabc","nonce":"n1"}}}' | base64 -w0)

curl -i -X POST http://127.0.0.1:3000/api/search \

-H "Content-Type: application/json" \

-H "X-PAYMENT: $PAYLOAD" \

-d '{"q":"x402"}'

```

看到 200、结果和 X-PAYMENT-RESPONSE 头,整条链路就通了。再重复发一次同样的 nonce,应该拿到 409,说明幂等生效。

常见坑与排错

402 回来了但客户端不付款。 先看响应体里有没有 accepts 且非空。客户端全靠它签名,字段缺一个(尤其是 asset、payTo、maxAmountRequired)就会静默放弃。

verify 一直返回无效,但签名看起来没问题。 九成是 resource 不一致。反代把 http 改成了 https、加了或去掉了尾部斜杠、改写了端口,都会让服务端重算的 resource 和客户端签的原文对不上。解决办法是别用 req.originalUrl 拼,而是从配置里读固定值,或者在反代上显式透传原始 host 与协议头。

金额差了三个数量级。 maxAmountRequired 是最小单位字符串。6 位小数的代币,0.01 要写 "10000"。另外 value 字段在授权里通常也是字符串,别传数字,大整数会精度丢失。

base64 解不开。 有的库默认输出 URL-safe 变体(- 和 _ 代替 + 和 /),解码时要对应。两端约定好标准 base64 最省事。

Nginx 后面拿不到 X-PAYMENT。 自定义请求头默认会透传,但如果你用了某些安全模块或 WAF,带下划线以外的自定义头可能被拦。用 curl -v 对比直连和走反代的差异,能快速定位。

浏览器调用看不到回执头。 跨域场景下 X-PAYMENT-RESPONSE 需要显式暴露:

```nginx

add_header Access-Control-Expose-Headers "X-PAYMENT-RESPONSE";

```

结算成功了但用户说被扣了两次。 通常是客户端重试导致的。服务端必须做幂等,键用 payer + nonce,别用请求体哈希。Redis 里给这个键设上过期时间,和授权的 validBefore 对齐。

facilitator 挂了怎么办。 不要在结算失败时还返回 200 把结果白送。要么返回 5xx 让客户端重试,要么返回结果但把 X-PAYMENT-RESPONSE 标记为未完成并落库待补。同时给 facilitator 调用加超时,本文示例里用了 AbortSignal.timeout。

授权时间窗对不上。 validAfter / validBefore 覆盖的时间段要能容纳“客户端签名到服务端结算”的整个耗时,maxTimeoutSeconds 也得比这个宽。时间给太紧,网络一抖就过期。

风控与成本控制

支付跑通只是开始,真正对外提供服务前,这几条建议至少落实前四条:

1. 价格按下游成本倒推。 搜索有第三方 API 成本、有模型摘要成本,把单次全链路成本算出来,留出余量再定价。别拍脑袋定一个整数。

2. 单钱包日限额。 在 seen 表旁边再维护一个 payer -> 当日累计金额 的计数,超限直接 402 拒绝,并提示“今日额度已满”。这能挡住被盗私钥的疯狂刷卡。

3. 单 IP 速率限制。 支付不能替代限流。一个付了钱的钱包仍然可能一秒打一千次,把你的下游打挂。

4. 结果缓存。 相同查询短期内命中缓存仍然正常收费(用户买的是“这次调用”),但你的边际成本降下来了。缓存键里要包含影响结果的参数,别串味。

5. 审计日志。 每次结算都落一条:时间、payer、金额、资源、交易哈希、耗时。对账和排查纠纷全靠它。

6. 退款与失败策略写清楚。 业务算完但结算失败、或者算到一半报错,钱怎么处理,要在对外文档里说明白。默认策略建议:业务失败不结算,结算失败返回错误让客户端重试。

7. 测试网与主网彻底隔离。 配置用环境变量分文件,别让测试脚本的私钥有机会指向主网。

下一步建议

链路通了之后,可以往这几个方向推进:

  • 多档定价:给报价单按接口或参数返回不同金额,比如普通检索一个价、带摘要的深度检索另一个价。客户端本来就会读 accepts,改价不需要改客户端。
  • 打包计费:一次付款授权多次调用,服务端用额度账本扣减,减少链上结算次数,降低小额支付的成本占比。
  • 自建 facilitator:调用量上来以后评估自建,减少对外部服务的依赖,同时也便于做内网结算。
  • 接到 MCP 或工具市场:把带钱包的 fetch 封成通用适配层,任何需要付费的工具都能复用同一套结算逻辑。
  • 监控与告警:给“402 下发次数”“verify 失败率”“settle 失败率”“单钱包异常增速”各配一条曲线,异常时第一时间知道。

最后提醒一句:协议字段、支持的链与资产、官方 SDK 的包名和导出,都在持续演进,落地前务必对照官方文档当前版本核一遍,别照抄网上过期的示例。

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。