在 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 上履約。
如果還在比較託管頁面、嵌入式元件與自建結帳頁,先看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。
上線前請驗證:
- 用相同且穩定的
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、USDT 付款的列印分享、精確金額、最終確認和現場核驗,並提供海報文案、異常處理表與活動結束清單,幫助商戶把掃碼、到帳和實際交付分別核對。
穩定幣支付與銀行卡支付怎麼選?本文比較客戶付款步驟、確認與銀行到帳、費用、退款和爭議處理,區分商戶自持 USDC、USDT 與服務商結算,提供統一成本記錄表、訂單防重複規則和雙通道試用清單,幫助團隊依據客戶需求判斷是否增加穩定幣付款選項。
數字商品如何用穩定幣收款?本文以報告、模板、課程和軟體授權為例,說明 USDC、USDT 付款怎樣關聯購買者、訂單與交付許可權,比較付款連結和一次性結帳,梳理最終確認、重複通知、下載失敗與退款處理,並提供試用清單,幫助商戶建立可核對的數字交付流程。
跨境企業如何用穩定幣收款?本文以服務合同與企業帳單為例,說明 USDC、USDT 的計價約定、網路選擇、付款指引、到帳確認和交付憑證,並梳理少付、遲到付款、退款與對帳要點,幫助跨境服務商評估客戶適用性,建立雙方可核對的收款流程。