StableOps
Guides

Next.js Integration

Integrate StableOps with Next.js App Router: create idempotent payment orders on the server, send wallet transfers, and fulfill from verified Webhooks.

This guide uses the App Router and the current TypeScript SDK.

Want to use StableOps hosted checkout to accept USDC in Next.js? Read the blog tutorial and check out the complete example project that deploys to Vercel with one click.

Install

pnpm add @stableops/api-sdk

Configure

STABLEOPS_API_KEY=sk_sandbox_...
STABLEOPS_WEBHOOK_SECRET=whsec_...

Create lib/stableops.ts:

import { StableOps } from '@stableops/api-sdk'

export const stableops = new StableOps({
  apiKey: process.env.STABLEOPS_API_KEY!,
})

This guide uses a sandbox key and Base Sepolia. Import a base-sepolia receiving address in Sandbox and prepare test USDC first. For production, switch the API key, accepted mainnet assets, and Live address pool together; changing only the key is not enough.

These snippets omit business authentication. In production, authenticate the user, check order ownership, and load the payable amount from trusted server-side product or invoice records rather than accepting a client-supplied price. Authorize order retrieval and cancellation as well.

Create an order

Create app/actions/payment.ts:

'use server'

import { revalidatePath } from 'next/cache'

import { stableops } from '@/lib/stableops'

export async function createPaymentOrder(formData: FormData) {
  const amount = String(formData.get('amount') ?? '')
  const merchantOrderId = String(formData.get('orderId') ?? '')

  const order = await stableops.paymentOrders.create(
    {
      merchantOrderId,
      amount,
      acceptedAssets: [{ chain: 'base-sepolia', asset: 'USDC' }],
      expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
      metadata: { source: 'nextjs_checkout' },
    },
    { idempotencyKey: merchantOrderId },
  )

  revalidatePath('/payments')
  return order
}

Show one payment candidate from the returned order:

{
  order.paymentInstructions[0] ? (
    <div>
      Send {order.amount} {order.paymentInstructions[0].asset} on{' '}
      {order.paymentInstructions[0].chain} to:
      <code>{order.paymentInstructions[0].address}</code>
    </div>
  ) : null
}

Verify webhooks

Create app/api/webhooks/stableops/route.ts:

import { SIGNATURE_HEADER, verifySignature } from '@stableops/api-sdk/webhooks'

const WEBHOOK_SECRET = process.env.STABLEOPS_WEBHOOK_SECRET!

export async function POST(req: Request) {
  const rawBody = await req.text()
  const result = verifySignature({
    secrets: [WEBHOOK_SECRET],
    header: req.headers.get(SIGNATURE_HEADER) ?? undefined,
    rawBody,
  })

  if (!result.ok) {
    return new Response(`invalid signature: ${result.reason}`, { status: 400 })
  }

  const event = JSON.parse(rawBody) as {
    type: string
    data: { payment_order_id?: string }
  }

  switch (event.type) {
    case 'payment.detected':
      break
    case 'payment.confirmed':
      break
    case 'payment.finalized':
      // Fulfill after your own idempotency check.
      break
    case 'payment.expired':
    case 'payment.reverted':
      break
  }

  return new Response('ok')
}

The empty branches demonstrate event routing, not production fulfillment. Persist the event and recoverable work before acknowledging it. Commit business updates and the processed marker together, or use a recoverable processing state. See reliable Webhook handling.

How is this guide?

Last updated

On this page