Back to Blog
Guides
2026-07-106 min readBy StableOps

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.

Next.js
USDC
Hosted Checkout

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 payment page and payment UI; the payer uses their own browser wallet, mobile wallet, or a manual transfer. Your app redirects the payer there and receives the payment result through a webhook.

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.

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. Store X-Event-Id in a table with a unique constraint before changing an order. A duplicate-key result means the event was already recorded and can safely return 200. Only the verified payment.finalized event should trigger irreversible fulfillment.

// 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 should be backed by a database uniqueness guarantee, not an in-memory map. Make fulfillOrderOnce idempotent by your internal order ID too: a crash after recording the event but before the side effect must be recoverable by a worker or reconciliation job. 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 fits the two durable records this flow needs: your internal orders and processed webhook event IDs. Give processed_events.event_id a unique index, and persist the relation between an internal order and StableOps' payment-order ID. This makes duplicate deliveries and support investigations routine database queries 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:

  1. Start checkout twice with the same stable cartId and confirm the same persisted order, merchantOrderId, idempotency key, and every request-body value—including checkoutExpiresAt—are reused.
  2. Pay Base Sepolia USDC before the 30-minute expiry and confirm the order page stays pending until the verified finalized event arrives.
  3. Replay the webhook and verify the unique X-Event-Id record prevents another fulfillment.
  4. Visit the success URL without paying and confirm it does not change the order to paid.

For the complete Supabase schema, Vercel configuration, and runnable route handlers, use the Next.js hosted Checkout example.

Related articles

Compare custodial gateways, direct wallet transfers, and non-custodial payment infrastructure for reliable stablecoin collection.

A practical model for confirmations, reorgs, finality, and merchant fulfillment.