How to Accept USDC Payments in Next.js
Create a hosted USDC Checkout Session in Next.js, redirect securely, and fulfill only from verified webhooks.
Hosted Checkout is the quickest way to accept USDC in a Next.js app without building wallet connection, network selection, payment instructions, or live payment-status UI yourself. Your server creates a Checkout Session; StableOps hosts the checkout and wallet-interaction UI; the payer uses their own browser wallet, mobile wallet, or a manual transfer. Your app redirects the payer there and receives payment-state changes through webhooks.
This guide uses Base Sepolia USDC and a 30-minute expiry so you can test safely. The production architecture is the same: keep merchant secrets on the server, make creation idempotent, and fulfill only after a verified payment.finalized webhook.
The complete deployable project is available in the Next.js hosted Checkout example.
What hosted Checkout owns
The hosted page presents the payment amount, Base Sepolia USDC option, receiving address, and payment progress. It can handle browser wallets, compatible mobile wallets when configured, and manual transfers. That keeps payment UI and wallet-specific behavior out of your application.
Your app still owns the business boundary:
- create the internal order and Checkout Session on the server;
- redirect the current payer to the returned Checkout URL;
- record payment events reliably; and
- release an order only after the final payment event.
Do not put a StableOps API key in a Client Component, browser JavaScript, or a NEXT_PUBLIC_ variable. NEXT_PUBLIC_ values are bundled for the browser. Use a server-only environment variable such as STABLEOPS_API_KEY instead.
STABLEOPS_API_KEY=sk_sandbox_...
STABLEOPS_WEBHOOK_SECRET=whsec_...
WALLETCONNECT_PROJECT_ID=...WALLETCONNECT_PROJECT_ID is an optional server-side variable. Get one from Reown Cloud and pass it to the Checkout Session to show a WalletConnect mobile-wallet entry on the hosted page. Leave it empty to keep browser-wallet and manual-transfer payment options.
Create the session in a Route Handler
Load or atomically create a durable internal order from a stable business identifier, such as the authenticated cart's cartId. Keep that cartId stable for every retry of the same checkout. On first creation, getOrCreateOrder(cartId) must persist checkoutExpiresAt (for example, now plus 30 minutes); later calls must return that same order and expiry. Use order.id for both merchantOrderId and the idempotency key. Do not create a new random key on each retry.
Stable idempotency keys require stable request bodies. On retries, every value passed to checkoutSessions.create—including amount, accepted assets, title, return URLs, expiresAt, and the WalletConnect project ID—must be derived from the persisted order or fixed configuration and remain unchanged.
// 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) // On first creation, persists checkoutExpiresAt for this cart.
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: 'Example order',
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 })
}After the client calls this route, redirect with window.location.assign(url). The Checkout URL is for the current payer, so do not place it in a shared cache, analytics log, or public order record.
Because this example uses amountMode: 'auto', StableOps may micro-adjust the amount to avoid an in-flight collision on a shared address. Hosted Checkout displays the returned checkout.paymentOrder.amount. Persist that actual amount together with checkout.paymentOrder.requestedAmount so customer support and reconciliation can distinguish what the customer paid from the base amount your application requested.
successUrl and cancelUrl are browser return paths, not payment proof. A user can close the window, open either URL manually, or return before a chain transfer reaches finality. Use them to restore the order page and show a pending state; fetch your server's current order state there rather than marking the order paid.
Verify raw webhooks and fulfill once
Register a webhook endpoint for payment events in StableOps. In a Next.js Route Handler, read req.text() before parsing JSON. Signature verification needs the exact raw body; parsing and serializing first changes the signed value.
Every delivery can be retried, replayed, or arrive after a later state. Reject a missing event ID, and process each verified event through one transaction that records the event, validates a legal state transition, and updates the order or inserts a fulfillment outbox job. Only the verified payment.finalized event should authorize irreversible fulfillment.
// 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 is the durable boundary. In one Serializable database transaction, it should insert the unique event ID, resolve the internal order from merchant_order_id or the saved StableOps Payment Order ID, reject state regressions with an explicit transition table, and update business state. For payment.finalized, either mark a purely database-backed entitlement idempotently or insert a fulfillment outbox job in that same transaction. A worker performs external side effects by internal order ID and records completion. This is the same boundary implemented by the complete example.
You may display payment.detected and payment.confirmed as progress, but do not ship goods, grant irreversible access, or post final ledger entries until payment.finalized.
Deploy on Supabase and Vercel
Supabase stores the durable records this flow needs: internal orders, Webhook events, and—when fulfillment calls an external system—outbox jobs. Give processed_events.event_id a unique index, persist the relation between an internal order and StableOps' Payment Order ID, and commit the event plus its state change or outbox job atomically. This makes duplicate delivery, replay, and support investigations routine database operations instead of timing-dependent logic.
Deploy the Next.js app to Vercel, add STABLEOPS_API_KEY, STABLEOPS_WEBHOOK_SECRET, APP_URL, and the optional WALLETCONNECT_PROJECT_ID to the project environment, then register the deployed /api/webhooks/stableops URL as your StableOps webhook endpoint. Use the deployed URL for successUrl and cancelUrl; do not rely on localhost return URLs in production.
Before going live, test these paths:
- Start checkout twice with the same stable
cartIdand confirm the same persisted order,merchantOrderId, idempotency key, and every request-body value—includingcheckoutExpiresAt—are reused. - Pay Base Sepolia USDC before the 30-minute expiry and confirm the order page stays pending until the verified finalized event arrives.
- Replay the webhook and verify the unique
X-Event-Idrecord prevents another state change or outbox job. - Visit the success URL without paying and confirm it does not change the order to paid.
- Deliver
payment.detectedafterpayment.finalizedand confirm the legal-transition table prevents the order from moving backward.
For the complete Supabase schema, Vercel configuration, and runnable route handlers, use the Next.js hosted Checkout example.
Related articles
Build USDC subscription payments with recurring invoices, payer-initiated checkout, and verified subscription settlement events.
Accept USDC payments into merchant-controlled wallets with payment orders, chain monitoring, finality, and reliable webhook fulfillment.
Learn crypto payment reconciliation across business orders, payment events, and on-chain transfers with a safe script and daily and monthly checklists.
An underpaid crypto payment, overpayment, wrong-network transfer, or late payment should never silently complete an order. Learn how to detect, resolve, and prevent each mismatch.