返回博客
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 上履约。

如果还在比较托管页面、嵌入式组件与自建收银台,先看USDC 与 USDT 的稳定币结账流程,确定付款信息、异常恢复和转化漏斗的设计,再按本文实现 Next.js 接口。

完整、可部署的项目见 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。

上线前请验证:

  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 或 USDT 转账关联到商户订单、确认、通知和对账。本文比较托管与非托管处理模式、支付 API、结账页、网络覆盖、费用、安全和异常处理能力,并提供可复用的供应商评估表,帮助企业选出适合自身资金控制与运营要求的方案。

稳定币支付手续费不只有链上 Gas。本文拆解 USDC 与 USDT 收款的网络费、平台费、归集退款、兑换点差、出入金和异常运营成本,提供可复用的每笔成功付款与基点成本公式、测量表和降本清单,帮助商户比较真实总成本并选择合适网络与计费模式。

企业如何接受稳定币支付?本文从 USDC、USDT 与网络选择讲到收款模式、订单匹配、链上确认、Webhook、退款和对账,比较托管网关、直接转账与非托管支付基础设施,并提供从沙盒测试到正式上线的实施清单,帮助商户建立可靠且资金自持的稳定币收款流程。

比较 USDC 与 USDT 的发行方、储备披露、赎回条件、网络覆盖、手续费和钱包兼容性,了解 SaaS、跨境服务与交易平台该接受哪种稳定币,如何按客户持币分布选择链与资产,并用 StableOps 为同一订单配置可验证的收款组合。