在 Next.js 中接收 USDC 支付
学习如何在 Next.js App Router 中由服务端创建托管 USDC 结账会话,安全跳转付款人,并验证 Webhook 签名、去重事件,只在付款最终确认后履约。文章提供可直接改造的接口路由、支付按钮和事件处理示例,并说明密钥隔离、返回地址校验与本地测试要点。
使用 Hosted Checkout,可以在 Next.js 中接收 USDC,而不必自己实现钱包连接、网络选择、支付指令和实时支付状态 UI。你的服务端创建 Checkout Session,StableOps 承载结账和钱包交互界面;付款人使用自己的浏览器钱包、手机钱包或手动转账完成支付。应用把付款人跳转过去,再从 Webhook 接收支付状态变化。
本文使用 Base Sepolia 上的 USDC 和 30 分钟有效期,方便安全地完成测试。生产环境的边界完全相同:密钥留在服务端、创建操作保持幂等、只在验签后的 payment.finalized Webhook 上履约。
完整、可部署的项目见 Next.js Hosted Checkout 示例。
Hosted Checkout 负责什么
Hosted Checkout 页面展示付款金额、Base Sepolia USDC、收款地址和支付进度;它可以处理浏览器钱包、配置后可用的手机钱包,以及手动转账。这样你无需把支付 UI 和各钱包的差异放进应用前端。
你的应用仍负责业务边界:
- 在服务端创建内部订单和 Checkout Session;
- 将当前付款人跳转到返回的 Checkout URL;
- 可靠地记录支付事件;以及
- 只在最终支付事件到达后履约。
不要把 StableOps API key 写入 Client Component、浏览器 JavaScript 或 NEXT_PUBLIC_ 环境变量。NEXT_PUBLIC_ 会被打包到浏览器。请使用仅在服务端可见的环境变量,例如 STABLEOPS_API_KEY。
STABLEOPS_API_KEY=sk_sandbox_...
STABLEOPS_WEBHOOK_SECRET=whsec_...
WALLETCONNECT_PROJECT_ID=...WALLETCONNECT_PROJECT_ID 是可选的服务端变量。从 Reown Cloud 获取后传给 Checkout Session,Hosted Checkout 页面会显示 WalletConnect 手机钱包入口;留空时仍可使用浏览器钱包和手动转账。
在 Route Handler 创建会话
从稳定的业务标识加载或原子创建持久化内部订单,例如已认证购物车的 cartId。同一笔 Checkout 的每次重试都必须使用相同的 cartId。首次创建时,getOrCreateOrder(cartId) 必须持久化 checkoutExpiresAt(例如当前时间加 30 分钟);后续调用必须返回同一订单和同一到期时间。随后用 order.id 同时作为 merchantOrderId 和幂等键。不要在每次重试时生成新的随机 key。
稳定的幂等键要求稳定的请求体。重试时传给 checkoutSessions.create 的每个值,包括金额、可接受资产、标题、返回 URL、expiresAt 和 WalletConnect project ID,都必须来自持久化订单或固定配置,并且保持不变。
// app/api/checkout/route.ts
import { StableOps } from '@stableops/api-sdk'
const stableops = new StableOps({
apiKey: process.env.STABLEOPS_API_KEY!,
})
export async function POST(req: Request) {
const cartId = await getAuthenticatedCartId(req)
const order = await getOrCreateOrder(cartId) // 首次创建时为该购物车持久化 checkoutExpiresAt。
const origin = process.env.APP_URL!
const checkout = await stableops.checkoutSessions.create(
{
merchantOrderId: order.id,
amount: order.amount,
amountMode: 'auto',
acceptedAssets: [{ chain: 'base-sepolia', asset: 'USDC' }],
expiresAt: order.checkoutExpiresAt,
title: '示例订单',
successUrl: `${origin}/orders/${order.id}?checkout=success`,
cancelUrl: `${origin}/orders/${order.id}?checkout=canceled`,
walletConnectProjectId: process.env.WALLETCONNECT_PROJECT_ID || undefined,
},
{ idempotencyKey: order.id },
)
if (!checkout.url) return new Response('checkout unavailable', { status: 502 })
return Response.json({ url: checkout.url })
}客户端调用这个 route 后,再用 window.location.assign(url) 跳转。Checkout URL 只属于当前付款人,不要把它放入共享缓存、分析日志或公开订单记录。
由于示例使用 amountMode: 'auto',StableOps 可能会微调金额,避免共享地址上的在途订单发生金额冲突。Hosted Checkout 展示返回的 checkout.paymentOrder.amount。应把这个实际应付金额和 checkout.paymentOrder.requestedAmount 一起持久化,便于客服和对账区分客户实际支付金额与应用请求的基准金额。
successUrl 和 cancelUrl 只是浏览器返回路径,不是支付凭据。用户可以关闭窗口、手动打开任一 URL,或在链上转账达到最终性前返回。用它们恢复订单页并展示“处理中”,然后读取服务端保存的当前订单状态;不要据此把订单标成已支付。
验证 raw body 的 Webhook,并且只履约一次
在 StableOps 中注册支付事件的 Webhook endpoint。在 Next.js Route Handler 中,先调用 req.text(),再解析 JSON。签名验证依赖完全相同的原始 body;先解析再序列化会改变被签名的内容。
每次投递都可能重试、重放,或者晚于后续状态到达。缺少事件 ID 时应拒绝请求;每个已验证事件都应在同一事务中完成事件记录、合法状态迁移检查,以及订单更新或履约 outbox 任务写入。只有已验证的 payment.finalized 事件能授权不可逆履约。
// app/api/webhooks/stableops/route.ts
import {
EVENT_ID_HEADER,
SIGNATURE_HEADER,
verifySignature,
} from '@stableops/api-sdk/webhooks'
export async function POST(req: Request) {
const rawBody = await req.text()
const verified = verifySignature({
secrets: [process.env.STABLEOPS_WEBHOOK_SECRET!],
header: req.headers.get(SIGNATURE_HEADER) ?? undefined,
rawBody,
})
if (!verified.ok) return new Response('invalid signature', { status: 400 })
const eventId = req.headers.get(EVENT_ID_HEADER)
if (!eventId) return new Response('missing event id', { status: 400 })
let event: {
type: string
data: { payment_order_id?: string; merchant_order_id?: string }
}
try {
event = JSON.parse(rawBody)
} catch {
return new Response('invalid JSON payload', { status: 400 })
}
await recordAndApplyPaymentEvent({ eventId, event })
return new Response('ok')
}recordAndApplyPaymentEvent 是持久化边界。它应在同一个 Serializable 数据库事务中插入唯一事件 ID,通过 merchant_order_id 或已保存的 StableOps Payment Order ID 找到内部订单,用明确的合法迁移表阻止状态倒退,再更新业务状态。对于 payment.finalized,可以在事务内幂等标记纯数据库权益;如果需要调用外部系统,则应在同一事务中写入履约 outbox 任务。worker 按内部订单 ID 执行外部副作用并记录完成状态。完整示例采用的就是这条边界。
可以把 payment.detected 和 payment.confirmed 展示为进度,但发货、开通不可逆权益和正式记账只能由 payment.finalized 触发。
部署到 Supabase 和 Vercel
Supabase 用于保存本流程的持久记录:内部订单、Webhook 事件,以及在履约需要调用外部系统时使用的 outbox 任务。为 processed_events.event_id 建立唯一索引,保存内部订单与 StableOps Payment Order ID 的关联,并原子提交事件及其状态更新或 outbox 任务。这样重复投递、重放和客服排查都成为稳定的数据库操作,而不依赖瞬时的内存状态。
将 Next.js 应用部署到 Vercel,在项目环境变量中设置 STABLEOPS_API_KEY、STABLEOPS_WEBHOOK_SECRET、APP_URL,以及可选的 WALLETCONNECT_PROJECT_ID,然后把部署后的 /api/webhooks/stableops URL 注册成 StableOps Webhook endpoint。生产环境的 successUrl 和 cancelUrl 要使用部署域名,不要依赖 localhost。
上线前请验证:
- 用相同且稳定的
cartId启动两次 Checkout,确认复用了同一持久化订单、merchantOrderId、幂等键和每个请求体字段(包括checkoutExpiresAt)。 - 在 30 分钟有效期内支付 Base Sepolia USDC,确认订单页在收到已验证的 finalized 事件前始终保持处理中。
- 重放同一 Webhook,确认唯一的
X-Event-Id记录阻止第二次状态更新或 outbox 入队。 - 未支付时直接访问 success URL,确认订单不会变成已支付。
- 在
payment.finalized之后再投递payment.detected,确认合法迁移表会阻止订单状态倒退。
Supabase 表结构、Vercel 配置和完整 Route Handler 可直接参考 Next.js Hosted Checkout 示例。
相关文章
学习如何用沙盒、测试网 USDC、Webhook 测试数据和上线核对清单测试加密货币支付,全程不承担真实资金风险。
用周期性账单、付款人主动结账和已验证的订阅结算事件构建 USDC 订阅。
使用支付订单、链上监听、最终性和可靠 Webhook,将 USDC 收入商户控制的钱包。
通过 StableOps x402 服务列表按类别、交易数据和在线测试发现适合 Agent 的付费接口,查看实时付款要求与变更记录。服务提供方也可提交多个接口,经有效性验证和人工审核后,面向更多开发者与 Agent 持续展示服务能力。