返回博客
Guides
2026-07-106 分钟阅读作者:StableOps

在 Next.js 中接收 USDC 支付

学习如何在 Next.js App Router 中由服务端创建托管 USDC 结账会话,安全跳转付款人,并验证 Webhook 签名、去重事件,只在付款最终确认后履约。文章提供可直接改造的接口路由、支付按钮和事件处理示例,并说明密钥隔离、返回地址校验与本地测试要点。

Next.js
USDC
Hosted Checkout

使用 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 一起持久化,便于客服和对账区分客户实际支付金额与应用请求的基准金额。

successUrlcancelUrl 只是浏览器返回路径,不是支付凭据。用户可以关闭窗口、手动打开任一 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.detectedpayment.confirmed 展示为进度,但发货、开通不可逆权益和正式记账只能由 payment.finalized 触发。

部署到 Supabase 和 Vercel

Supabase 用于保存本流程的持久记录:内部订单、Webhook 事件,以及在履约需要调用外部系统时使用的 outbox 任务。为 processed_events.event_id 建立唯一索引,保存内部订单与 StableOps Payment Order ID 的关联,并原子提交事件及其状态更新或 outbox 任务。这样重复投递、重放和客服排查都成为稳定的数据库操作,而不依赖瞬时的内存状态。

将 Next.js 应用部署到 Vercel,在项目环境变量中设置 STABLEOPS_API_KEYSTABLEOPS_WEBHOOK_SECRETAPP_URL,以及可选的 WALLETCONNECT_PROJECT_ID,然后把部署后的 /api/webhooks/stableops URL 注册成 StableOps Webhook endpoint。生产环境的 successUrlcancelUrl 要使用部署域名,不要依赖 localhost。

上线前请验证:

  1. 用相同且稳定的 cartId 启动两次 Checkout,确认复用了同一持久化订单、merchantOrderId、幂等键和每个请求体字段(包括 checkoutExpiresAt)。
  2. 在 30 分钟有效期内支付 Base Sepolia USDC,确认订单页在收到已验证的 finalized 事件前始终保持处理中。
  3. 重放同一 Webhook,确认唯一的 X-Event-Id 记录阻止第二次状态更新或 outbox 入队。
  4. 未支付时直接访问 success URL,确认订单不会变成已支付。
  5. payment.finalized 之后再投递 payment.detected,确认合法迁移表会阻止订单状态倒退。

Supabase 表结构、Vercel 配置和完整 Route Handler 可直接参考 Next.js Hosted Checkout 示例

相关文章

学习如何用沙盒、测试网 USDC、Webhook 测试数据和上线核对清单测试加密货币支付,全程不承担真实资金风险。

用周期性账单、付款人主动结账和已验证的订阅结算事件构建 USDC 订阅。

使用支付订单、链上监听、最终性和可靠 Webhook,将 USDC 收入商户控制的钱包。

通过 StableOps x402 服务列表按类别、交易数据和在线测试发现适合 Agent 的付费接口,查看实时付款要求与变更记录。服务提供方也可提交多个接口,经有效性验证和人工审核后,面向更多开发者与 Agent 持续展示服务能力。