在 Next.js 中接收 USDC 支付
在 Next.js 中创建托管 USDC 收银台会话,安全跳转,并且只根据已验证的 Webhook 履约。
使用托管 Checkout,可以在 Next.js 中接收 USDC,而不必自己实现钱包连接、网络选择、支付指令和实时支付状态 UI。你的服务端创建 Checkout Session,StableOps 托管支付页面和支付 UI;付款人使用自己的浏览器钱包、手机钱包或手动转账完成支付。应用把付款人跳转过去,再从 Webhook 接收支付结果。
本文使用 Base Sepolia 上的 USDC 和 30 分钟有效期,方便安全地完成测试。生产环境的边界完全相同:密钥留在服务端、创建操作保持幂等、只在验签后的 payment.finalized Webhook 上履约。
完整、可部署的项目见 Next.js 托管 Checkout 示例。
托管 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,托管页会显示 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 只属于当前付款人,不要把它放入共享缓存、分析日志或公开订单记录。
successUrl 和 cancelUrl 只是浏览器返回路径,不是支付凭据。用户可以关闭窗口、手动打开任一 URL,或在链上转账达到最终性前返回。用它们恢复订单页并展示“处理中”,然后读取服务端保存的当前订单状态;不要据此把订单标成已支付。
验证 raw body 的 Webhook,并且只履约一次
在 StableOps 中注册支付事件的 Webhook endpoint。在 Next.js Route Handler 中,先调用 req.text(),再解析 JSON。签名验证依赖完全相同的原始 body;先解析再序列化会改变被签名的内容。
每次投递都可能重试。在修改订单前,把 X-Event-Id 写入带唯一约束的数据表。遇到重复键说明该事件已经记录,可以安全返回 200。只有已验证的 payment.finalized 事件能触发不可逆履约。
// app/api/webhooks/stableops/route.ts
import { 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('X-Event-Id')
if (!eventId || !(await recordEventOnce(eventId))) {
return new Response('ok')
}
const event = JSON.parse(rawBody) as {
type: string
data: { payment_order_id: string }
}
if (event.type === 'payment.finalized') {
await fulfillOrderOnce(event.data.payment_order_id)
}
return new Response('ok')
}recordEventOnce 应由数据库唯一性保证,而不是内存 Map。fulfillOrderOnce 也要按你的内部订单 ID 设计为幂等:如果事件已记录、但副作用前发生崩溃,worker 或对账任务必须能恢复。可以把 payment.detected 和 payment.confirmed 展示为进度,但发货、开通不可逆权益和正式记账只能由 payment.finalized 触发。
部署到 Supabase 和 Vercel
Supabase 很适合保存本流程需要的两类持久记录:内部订单和已处理的 Webhook 事件 ID。为 processed_events.event_id 建立唯一索引,并保存内部订单与 StableOps payment-order ID 的关联。这样重复投递和客服排查都是稳定的数据库查询,而不依赖瞬时的内存状态。
将 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记录阻止第二次履约。 - 未支付时直接访问 success URL,确认订单不会变成已支付。
Supabase 表结构、Vercel 配置和完整 Route Handler 可直接参考 Next.js 托管 Checkout 示例。
相关文章
对比托管网关、原始钱包转账和非托管支付基础设施,选择可靠的稳定币收款方案。
解释确认数、reorg、finalized 状态与商户履约的关系。