OBHPayments
↑↓ navigate↵ openSearches titles and topics
Documentation

Guide

Next.js integration

A working App Router flow for this repository’s stack: route handlers for mutations, a server component for the return page, and a signed webhook route. No client component ever imports the SDK.

The shape of the integration

Next.js blurs the server/client boundary, so be explicit. Put the SDK in a module marked server-only, and reach it from:

  • a route handler (app/api/.../route.ts) for checkout,
  • a server component page for the return screen,
  • a server action for form-driven checkout,
  • a route handler for webhooks.

Client components should only receive the resulting checkoutUrl or a rendered status — never the client instance or the key.

1. A server-only client

lib/obh.tsts
import "server-only" import { OBHPayments } from "@obhco/payments" if (!process.env.OBH_SERVICE_KEY) {  throw new Error("OBH_SERVICE_KEY is not configured.")} export const obh = new OBHPayments({  secretKey: process.env.OBH_SERVICE_KEY,  timeoutMs: 15_000,})

import "server-only" makes the build fail if a client component ever imports this file. It is a stronger and earlier guard than the SDK’s runtime check.

2. Start checkout

Use a route handler when a form or a client fetch triggers checkout. It runs on the server, can read secrets, and can redirect the browser.

app/api/checkout/route.tsts
import { NextResponse } from "next/server"import { isOBHPaymentsError } from "@obhco/payments" import { obh } from "@/lib/obh"import { db } from "@/lib/db" export async function POST(request: Request) {  const { orderId } = (await request.json()) as { orderId: string }   const order = await db.order.findUniqueOrThrow({    where: { id: orderId },  })   try {    const payment = await obh.startPayment({      externalOrderId: order.number,      amount: order.total,      currency: "IRR",      description: order.title,      returnUrl: new URL(        "/payments/return",        request.url      ).toString(),      metadata: { orderId: order.id },      idempotencyKey: `order-${order.id}-attempt-${order.attempt}`,    })     await db.order.update({      where: { id: order.id },      data: { paymentReference: payment.reference },    })     if (!payment.checkoutUrl) {      return NextResponse.json({ reference: payment.reference })    }     return NextResponse.json({ checkoutUrl: payment.checkoutUrl })  } catch (error) {    if (isOBHPaymentsError(error)) {      return NextResponse.json(        {          error: error.code,          message: error.message,          requestId: error.requestId,        },        { status: error.status ?? 502 }      )    }     throw error  }}

Return URL must be allowlisted

The URL built above must exactly match the service allowlist in OBH Admin. Building it from request.url is convenient, but check the origin in production — proxies and preview deployments can change it.

3. The return page

In Next.js 16, searchParams is a promise. Read the reference, retrieve the payment, and render based on the status. The page is dynamic because it reads search params, so it will not be statically cached with a stale status.

app/payments/return/page.tsxtsx
import { notFound } from "next/navigation"import { isOBHPaymentsError, type Payment } from "@obhco/payments" import { obh } from "@/lib/obh" async function loadPayment(reference: string): Promise<Payment | null> {  try {    return await obh.getPayment(reference)  } catch (error) {    if (      isOBHPaymentsError(error) &&      error.code === "PAYMENT_NOT_FOUND"    ) {      return null    }     throw error  }} export default async function ReturnPage({  searchParams,}: {  searchParams: Promise<{ reference?: string }>}) {  const { reference } = await searchParams   if (!reference) {    notFound()  }   const payment = await loadPayment(reference)   if (!payment) {    notFound()  }   if (payment.status === "completed") {    return (      <main>        <h1>Payment confirmed</h1>        <p>Reference: {payment.reference}</p>      </main>    )  }   if (payment.status === "pending") {    return (      <main>        <h1>Confirming your payment</h1>        <p>This usually takes a few seconds. Refresh to check again.</p>      </main>    )  }   return (    <main>      <h1>Payment not completed</h1>      <p>Status: {payment.status}</p>    </main>  )}

4. Idempotent fulfilment

Both the return page and the webhook will try to fulfil the order. Make the database do the arbitration: only one caller may move the order from unpaid to paid.

lib/fulfilment.tsts
import "server-only" import { db } from "@/lib/db" export async function markOrderPaidOnce(reference: string) {  const updated = await db.order.updateMany({    where: {      paymentReference: reference,      paymentStatus: { not: "paid" },    },    data: {      paymentStatus: "paid",      paidAt: new Date(),    },  })   if (updated.count === 0) {    return { fulfilled: false }  }   await grantAccess(reference)  return { fulfilled: true }}

updateMany with a status guard is atomic enough for this purpose: the second caller updates zero rows and does not grant access twice.

5. The webhook route

The full verification code is in Webhooks. The route uses the Node.js runtime, reads the raw body, verifies the HMAC, claims the event id, retrieves the payment, and fulfils.

app/api/webhooks/obh/route.tsts
export const runtime = "nodejs" // The handler from the Webhooks page:// - reads request.text() before JSON.parse// - checks OBH-Timestamp against a 5-minute window// - verifies HMAC-SHA256(timestamp + "." + rawBody)// - claims OBH-Event-Id and returns 2xx

Next.js pitfalls

1

“use client” at the top of the module

A client component cannot read server environment variables, and the SDK throws in a browser. Move the call behind a route handler or server action.

2

Passing the client to a client component

The client instance closes over the secret. Pass only plain data — a checkout URL, a reference, or a status string.

3

Caching a payment response

Do not wrap getPayment in use cache or a long-lived fetch cache. A cached pending can outlive the payment. Keep status reads uncached.

4

Using the browser return URL as state

The return page can be refreshed or opened by anyone. It must retrieve the payment; it must not set the order to paid based on the URL alone.

5

Forgetting the Node.js runtime for the webhook

HMAC verification uses node:crypto. Declare export const runtime = "nodejs" unless you rewrite the comparison with Web Crypto.

Deployment checklist

  • Set OBH_SERVICE_KEY and OBH_WEBHOOK_SECRET as server-side secrets (and set OBH_SIMULATOR="false" or omit it in production).
  • Register the exact production return URL in OBH Admin.
  • Register the webhook URL and create a signing secret.
  • Schedule the webhook retry worker if you run OBH yourself.
  • Alert on repeated AUTH_INVALID or signature failures.
  • Confirm the SDK runs on a Node.js runtime; edge is not officially tested.
  • Remember: no LIVE credential exists yet. Do not enable real checkout without an authorized live adapter.