返回部落格
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 示例

相關文章

穩定幣支付手續費不只有鏈上 Gas。本文拆解 USDC 與 USDT 收款的網路費、平台費、歸集退款、兌換點差、出入金和異常營運成本,提供可複用的每筆成功付款與基點成本公式、測量表和降本清單,幫助商戶比較真實總成本並選擇合適網路與計費模式。

企業如何接受穩定幣支付?本文從 USDC、USDT 與網路選擇講到收款模式、訂單匹配、鏈上確認、Webhook、退款和對帳,比較託管閘道器、直接轉帳與非託管支付基礎設施,並提供從沙盒測試到正式上線的實施清單,幫助商戶建立可靠且資金自持的穩定幣收款流程。

比較 USDC 與 USDT 的發行方、儲備披露、贖回條件、網路覆蓋、手續費和錢包相容性,瞭解 SaaS、跨境服務與交易平台該接受哪種穩定幣,如何按客戶持幣分佈選擇鏈與資產,並用 StableOps 為同一訂單設定可驗證的收款組合。

這份 2026 年穩定幣支付 API 選型指南從資金託管、訂單匹配、鏈上最終性、Webhook、異常處理、對帳、測試與資料遷移十個維度評估產品,並提供適用於 SaaS、交易平台和 AI Agent 服務的概念驗證清單與供應商評分表。