Next.js App Router
Small server routes for guest Storefront checkout and signed webhook delivery.
Next.js Route Handlers use standard Web Request and Response, so Cipay adapters can be exported directly. Use /api imports only in server modules and /react imports in client components.
Buyer route
// app/api/cipay/buyer/[...path]/route.ts
import { createCipayBuyerHandler, createCipayClient } from "@cipay/client-sdk/api";
export const runtime = "nodejs";
const handler = createCipayBuyerHandler({
basePath: "/api/cipay/buyer",
client: createCipayClient({ apiKey: process.env.CIPAY_SANDBOX_API_KEY!, mode: "sandbox" }),
storefront: { locator: process.env.CIPAY_STOREFRONT_LOCATOR! },
});
export const GET = handler;
export const POST = handler;
For public catalog only, resolveCustomer is optional. The handler refuses account operations with 401 when no verified customer mapping is available. Apply your own rate limit to exposed routes as appropriate. The handler rejects cross-origin browser writes and binds requests to the configured locator.
React catalog and hosted checkout
Use the SDK UI components when their semantic catalog markup fits. ProductsList fetches Cipay products and ProductCard renders each product and its offers; use useProductsList() only when you need a fully custom layout. The sibling checkout demo uses this component path, with shadcn styling around the SDK components.
"use client";
import { useState } from "react";
import {
CipayProvider,
ProductCard,
ProductsList,
type BuyerProduct,
} from "@cipay/client-sdk/react";
export function Catalog({ storefrontOrigin }: { storefrontOrigin: string }) {
return (
<CipayProvider endpoint="/api/cipay/buyer">
<ProductsList
input={{ pageSize: 25 }}
renderProduct={(product) => (
<StorefrontProduct product={product} storefrontOrigin={storefrontOrigin} />
)}
/>
</CipayProvider>
);
}
function StorefrontProduct({
product,
storefrontOrigin,
}: {
product: BuyerProduct;
storefrontOrigin: string;
}) {
const [selectedOfferId, setSelectedOfferId] = useState<string>();
const offer = product.offers.find(({ id }) => id === selectedOfferId) ?? product.offers[0];
const url = new URL(`/${encodeURIComponent(product.id)}/checkout`, storefrontOrigin);
if (offer) url.searchParams.set("offerId", offer.id);
return (
<ProductCard
product={product}
selectedOfferId={offer?.id}
onOfferChange={setSelectedOfferId}
footer={offer ? <a href={url.toString()}>Open checkout</a> : null}
/>
);
}
If you are building custom UI, the equivalent hook is useProductsList(). In either case, use a normal anchor to the configured Storefront HTTPS origin, with a product path and published offerId query parameter:
const url = new URL(`/${encodeURIComponent(product.id)}/checkout`, storefrontOrigin);
url.searchParams.set("offerId", offer.id);
return <a href={url.toString()}>Open checkout</a>;
The browser loads catalog data from your same-origin buyer route and opens Cipay Storefront for buyer details and payment. Your Next.js server does not create or manage the payment in this flow. useCheckoutCreate and CheckoutButton are for the separate API-managed flow that returns a hosted URL.
Webhook route
// app/api/cipay/webhooks/route.ts
import { createCipayWebhookHandler } from "@cipay/client-sdk/api";
import { loadEndpoint } from "@/lib/webhooks";
import { persistCipayWebhookOnce } from "@/lib/cipay-webhook-store";
export const runtime = "nodejs";
export const POST = createCipayWebhookHandler({
endpoint: loadEndpoint,
persistOnce: persistCipayWebhookOnce,
});
loadEndpoint() must return the endpoint’s expected merchantId, environment (sandbox, production, or null for organization events), and current/overlapping signingSecrets. persistCipayWebhookOnce() must atomically claim the event ID, compare the SHA-256 hash for duplicates, and enqueue a durable job; handle fulfillment from that job, not this route. Do not store the raw payload just for inspection. The adapter limits the raw body to 1 MiB, verifies signature/timestamp/event ID and scope, then responds 202 claimed, 204 duplicate, 400 invalid, 409 conflicting bytes, 413 oversized, 503 unconfigured, or 500 claim failure. Details are in Webhooks.
The demo’s durable-job processor may log { eventId, eventType } in the Next.js server terminal. It intentionally has no browser event inspector. A production processor must make its business effect idempotent.
Optional Better Auth account mapping
Better Auth is not required for guest checkout. If your app already uses it, resolve the signed-in user on the server and look up the Cipay customer ID owned by that user:
resolveCustomer: async (request) => {
const session = await auth.api.getSession({ headers: request.headers });
if (!session) return null;
const customerId = await findOwnedCipayCustomerId(session.user.id);
return customerId ? { subject: session.user.id, customerId } : null;
}
auth and findOwnedCipayCustomerId are your application code, not SDK exports. Never accept customerId from browser input. Set the React provider’s non-secret sessionKey when the verified user changes to partition account caches.
Local HTTPS checklist
Run Cipay API, workers, Storefront, and your demo before testing delivery. Portless child processes trust the local CA automatically; a separate Node script needs NODE_EXTRA_CA_CERTS=$HOME/.portless/ca.pem at process startup. Trust the CA in your browser with portless trust if needed. For sandbox delivery to a .localhost receiver, configure the exact HTTPS origin in CIPAY_SANDBOX_WEBHOOK_LOCAL_ORIGINS; never disable TLS verification or allow all private IPs. Restart workers after changing the allowlist or worker code. Checkout events wake the worker for immediate processing; the */1 * * * * worker cron remains the fallback. Failed webhook attempts start with a 30-second backoff and double up to the 24-hour retry window.