Developers · Hashry Pay
Hashry Pay for Next.js
Add card payments to a Next.js app. Route handlers hold your key. A React page shows the card form. Every file you need is on this page, in order.
What you'll build
At the end, /orders/test-order/pay shows the card form. You pay with a test card, and
your server marks the order paid. You will add these files:
.env.local your settings
lib/shop.ts your orders (a stand-in database)
lib/payments.ts the payment steps
lib/hashry.ts the Hashry client
app/api/orders/[id]/payment/route.ts starts the payment
app/api/orders/[id]/confirm/route.ts confirms it with Hashry
app/api/orders/[id]/return/route.ts the return_url
app/api/webhooks/hashry/route.ts the webhook URL
components/Pay.tsx the card form component
app/orders/[id]/pay/page.tsx the pay page
next.config.mjs the build guardThe four routes
Your server gets four routes. The page calls the first two. Hashry calls the last one.
| Route | What it does |
|---|---|
POST /api/orders/{id}/payment | Starts the payment and answers { checkout }: the four checkout values, or null when there is no card form to show. Also renews an expired card form, and
starts a new attempt after every card was declined. |
POST /api/orders/{id}/confirm | Asks Hashry for the latest outcome (refresh) and updates the order. Answers { id, status, payment_status }. |
GET /api/orders/{id}/return | The return_url. The shopper lands here after the full payment page. It
confirms, then sends the shopper back to the pay page. |
POST /api/webhooks/hashry | The webhook URL. Checks the signature, skips events it has seen, reads the payment from Hashry and updates the order. |
Before you start
- A test key from the Hashry admin: the client ID and the client secret (
hsk_test_…). See Set up in the Hashry admin. https://localhost:3000added to both lists on the admin's Test Sandbox tab: Allowed return origins and Allowed embed origins.- Node 22.12 or newer.
- A Next.js app with the App Router. To start a new one:
Terminal npx create-next-app@latest my-shop --ts --app --no-src-dir --import-alias "@/*" cd my-shop
Step by step
- Install the packages.
Terminal npm install @hashrytech/pay-node @hashrytech/pay-react server-only npm install --save-dev @hashrytech/pay-build-guard - Add your settings. Create
.env.localin the project folder. Put in your test key.PUBLIC_BASE_URLis the address of your dev server..env.local # Secrets. Server only: never commit this file, and never give these names # a public prefix (NEXT_PUBLIC_, VITE_, PUBLIC_). HASHRY_CLIENT_ID=your-client-id HASHRY_CLIENT_SECRET=hsk_test_... HASHRY_ENVIRONMENT=sandbox # You get this one when you set up webhooks (see Test and go live). HASHRY_WEBHOOK_SECRET=hwhsec_test_... # Not secret: the https address shoppers open. It must be on Allowed embed # origins and Allowed return origins in the Hashry admin. PUBLIC_BASE_URL=https://localhost:3000 - Add your orders. This file stands in for your database. It keeps orders in
memory and has one test order,
test-order. ChangeJMDto your store's currency. Later, swap in your real database and keep the same functions.lib/shop.ts // lib/shop.ts: your own orders database. This in-memory version is only for // trying things out: it forgets everything on restart. Use your real database. import { randomUUID } from "node:crypto"; export interface Order { id: string; email: string; // Set by your server, never by the browser. A decimal string, for example "2500.00". amount: string; currency: string; // Only your server decides "paid". status: "unpaid" | "paid" | "review"; // The last payment status your server got from Hashry. paymentStatus: string | null; paymentIntentUid: string | null; // Goes up when a payment expired or was canceled and a new one is needed. paymentNumber: number; } const orders = new Map<string, Order>(); const handledEvents = new Set<string>(); // A test order, so you can try the pay page at /orders/test-order/pay. Use your // store's currency. Delete it when you use your real database. orders.set("test-order", { id: "test-order", email: "shopper@example.com", amount: "2500.00", currency: "JMD", status: "unpaid", paymentStatus: null, paymentIntentUid: null, paymentNumber: 1, }); export async function createOrder(email: string, amount: string, currency: string): Promise<Order> { const order: Order = { id: randomUUID(), email, amount, currency, status: "unpaid", paymentStatus: null, paymentIntentUid: null, paymentNumber: 1, }; orders.set(order.id, order); return order; } export async function getOrder(id: string): Promise<Order | null> { return orders.get(id) ?? null; } export async function saveOrder(order: Order): Promise<void> { orders.set(order.id, order); } export async function isEventHandled(eventId: string): Promise<boolean> { return handledEvents.has(eventId); } // In a real database, save the event ID and the order change in one transaction. export async function markEventHandled(eventId: string): Promise<void> { handledEvents.add(eventId); } - Add the payment steps. This file starts, renews and confirms payments, and
applies webhooks. Copy it as it is.
lib/payments.ts // lib/payments.ts: the payment steps. The same file works in Next.js, Express and // SvelteKit. Each framework passes in its own Hashry client. import { ApiConnectionError, ApiError, type Hashry, type PaymentIntent, type WebhookEvent, } from "@hashrytech/pay-node"; import { getOrder, isEventHandled, markEventHandled, saveOrder, type Order } from "./shop"; // The four values the page needs. Nothing here is secret. export interface CheckoutSession { embed_url: string; transaction_uid: string; status_url: string; status_token: string; } // What the browser may see about an order. export function orderView(order: Order) { return { id: order.id, status: order.status, payment_status: order.paymentStatus }; } // A card form can still be shown (or renewed) in these states. const OPEN = new Set(["awaiting_customer", "redirect_issued"]); function cents(amount: string): number | null { const match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(amount.trim()); if (!match) return null; return Number(match[1]) * 100 + Number((match[2] ?? "0").padEnd(2, "0")); } // Save what Hashry says. "paid" only when the payment succeeded for this // order's exact amount, currency and reference. A paid order stays paid. async function record(order: Order, intent: PaymentIntent): Promise<Order> { order.paymentStatus = intent.status; if (order.status !== "paid") { if (intent.status === "succeeded") { const amount = cents(intent.amount); const matches = amount !== null && amount === cents(order.amount) && intent.currency === order.currency && intent.external_reference === order.id; if (!matches) console.warn(`Payment ${intent.uid} does not match order ${order.id}.`); order.status = matches ? "paid" : "review"; } else if (intent.status === "payment_review") { order.status = "review"; } } await saveOrder(order); return order; } // Start the payment, renew a card form that expired, or retry after a decline. // Returns null when there is no card form to show (paid, or being checked). // `origin` is your shop's https origin, for example https://shop.example.com. export async function startPayment( hashry: Hashry, order: Order, origin: string, ): Promise<CheckoutSession | null> { if (order.status !== "unpaid") return null; const embedded = { presentation: "embedded" as const, embed_origin: origin }; // The answer holds embed_url and status_token. Never log it whole. let intent: PaymentIntent | null = null; if (order.paymentIntentUid) { const current = await hashry.paymentIntents.retrieve(order.paymentIntentUid); const latest = Math.max(1, ...current.attempts.map((a) => a.attempt_number)); if (current.status === "requires_payment_method") { // Every card the frame allowed was declined (it takes another card by itself // until then). Start a new attempt on the same payment, with a new key. intent = await hashry.paymentIntents.createAttempt(current.uid, embedded, { idempotencyKey: `order-${order.id}-attempt-${latest + 1}`, }); } else if (OPEN.has(current.status) && latest > 1) { // A retry's card form expired unused. Its own key again renews it. intent = await hashry.paymentIntents.createAttempt(current.uid, embedded, { idempotencyKey: `order-${order.id}-attempt-${latest}`, }); } else if (current.status === "expired" || current.status === "canceled") { // Over, and nothing was charged. Start a new payment with a new key. order.paymentIntentUid = null; order.paymentNumber += 1; } else if (!OPEN.has(current.status)) { // succeeded, processing or payment_review: no new card form. await record(order, current); return null; } } // One key per order and payment number. Sending it again is safe: Hashry // returns the same payment, with a fresh card form if the old one expired // unused. A replay must send the same details, so they come from the order. intent ??= await hashry.paymentIntents.create( { amount: order.amount, currency: order.currency, description: `Order ${order.id}`, external_reference: order.id, payer: { email: order.email }, return_url: `${origin}/api/orders/${order.id}/return`, cancel_url: `${origin}/cart`, ...embedded, }, { idempotencyKey: `order-${order.id}-pay-${order.paymentNumber}` }, ); order.paymentIntentUid = intent.uid; order.paymentStatus = intent.status; await saveOrder(order); if (!intent.embed_url || !intent.transaction_uid || !intent.status_url || !intent.status_token) { // This card form was already used. Ask the gateway how it ended. await record(order, await hashry.paymentIntents.refresh(intent.uid)); return null; } return { embed_url: intent.embed_url, transaction_uid: intent.transaction_uid, status_url: intent.status_url, status_token: intent.status_token, }; } // Ask Hashry now. refresh checks with the gateway first, so it is right even // when a webhook is late. Call it before you fulfil an order. export async function confirmPayment(hashry: Hashry, order: Order): Promise<Order> { if (order.status === "paid" || !order.paymentIntentUid) return order; return record(order, await hashry.paymentIntents.refresh(order.paymentIntentUid)); } // Apply a verified webhook once. The event is a cue: the payment read from // Hashry is the truth. export async function handleWebhookEvent(hashry: Hashry, event: WebhookEvent): Promise<void> { if (await isEventHandled(event.id)) return; if (event.paymentIntentUid) { const intent = await hashry.paymentIntents.retrieve(event.paymentIntentUid); const order = intent.external_reference ? await getOrder(intent.external_reference) : null; if (order) await record(order, intent); } await markEventHandled(event.id); } // True for a failed Hashry call: a refusal from the API, or no answer at all. export function isHashryCallError(error: unknown): error is ApiError | ApiConnectionError { return error instanceof ApiError || error instanceof ApiConnectionError; } // A JSON error for the browser. Hashry's error codes are safe to show. export function errorResponse(error: unknown): { status: number; body: { error: string; retry: boolean }; } { if (!isHashryCallError(error)) throw error; // The SDK's error messages never hold a secret, a token or an embed_url. console.warn(error.message); // retry: true means calling the same route again is safe and needed. It // sends the same idempotency key, so no second payment can start. if (error instanceof ApiConnectionError) { return { status: 503, body: { error: "hashry_unreachable", retry: true } }; } return { status: 502, body: { error: error.code, retry: error.outcomeUnknown } }; } - Create the Hashry client. It reads your settings. The
server-onlyimport stops the build if browser code imports this file.lib/hashry.ts // lib/hashry.ts // "server-only" fails the build if a client component imports this file. import "server-only"; import { Hashry, type Environment } from "@hashrytech/pay-node"; let client: Hashry | null = null; // Created on first use, so `next build` works without the secrets set. They // have no NEXT_PUBLIC_ prefix, so Next.js never puts them in browser code. export function hashry(): Hashry { client ??= new Hashry({ clientId: process.env.HASHRY_CLIENT_ID ?? "", clientSecret: process.env.HASHRY_CLIENT_SECRET ?? "", // The SDK refuses anything but "sandbox" or "live", and a key for the other mode. environment: (process.env.HASHRY_ENVIRONMENT || "sandbox") as Environment, }); return client; } // Your shop's https origin, for example https://shop.example.com. The card // form is framed there, and the return URL starts with it. export function siteOrigin(): string { const origin = (process.env.PUBLIC_BASE_URL ?? "").replace(/\/+$/, ""); if (!origin.startsWith("https://")) throw new Error("Set PUBLIC_BASE_URL to your https origin."); return origin; } // During a secret rotation, set both. The new one goes first. export function webhookSecrets(): string[] { return [process.env.HASHRY_WEBHOOK_SECRET, process.env.HASHRY_WEBHOOK_SECRET_PREVIOUS] .map((value) => value?.trim() ?? "") .filter(Boolean); } - Add the four routes.
app/api/orders/[id]/payment/route.ts // app/api/orders/[id]/payment/route.ts // Start the payment, renew an expired card form, or retry after a decline. import { hashry, siteOrigin } from "@/lib/hashry"; import { errorResponse, startPayment } from "@/lib/payments"; import { getOrder } from "@/lib/shop"; export const runtime = "nodejs"; export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; const order = await getOrder(id); if (!order) return Response.json({ error: "order_not_found" }, { status: 404 }); try { return Response.json({ checkout: await startPayment(hashry(), order, siteOrigin()) }); } catch (error) { const { status, body } = errorResponse(error); return Response.json(body, { status }); } }app/api/orders/[id]/confirm/route.ts // app/api/orders/[id]/confirm/route.ts // Confirm: ask Hashry now (refresh), then answer with your server's view of the order. import { hashry } from "@/lib/hashry"; import { confirmPayment, errorResponse, orderView } from "@/lib/payments"; import { getOrder } from "@/lib/shop"; export const runtime = "nodejs"; export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; const order = await getOrder(id); if (!order) return Response.json({ error: "order_not_found" }, { status: 404 }); try { return Response.json(orderView(await confirmPayment(hashry(), order))); } catch (error) { const { status, body } = errorResponse(error); return Response.json(body, { status }); } }app/api/orders/[id]/return/route.ts // app/api/orders/[id]/return/route.ts // The return_url. The shopper comes back here from the full payment page, for // example after the bank's check. Ask Hashry, then send the shopper back to the pay page. It shows the result. import { hashry, siteOrigin } from "@/lib/hashry"; import { confirmPayment, isHashryCallError } from "@/lib/payments"; import { getOrder } from "@/lib/shop"; export const runtime = "nodejs"; export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; const order = await getOrder(id); if (!order) return Response.json({ error: "order_not_found" }, { status: 404 }); try { await confirmPayment(hashry(), order); } catch (error) { // The pay page checks again, and the webhook also brings the answer. if (!isHashryCallError(error)) throw error; console.warn(error.message); } return Response.redirect(`${siteOrigin()}/orders/${encodeURIComponent(order.id)}/pay`, 303); }app/api/webhooks/hashry/route.ts // app/api/webhooks/hashry/route.ts // Give Hashry this URL: https://<your host>/api/webhooks/hashry import { WebhookError, verifyWebhook, type WebhookEvent } from "@hashrytech/pay-node"; import { hashry, webhookSecrets } from "@/lib/hashry"; import { errorResponse, handleWebhookEvent } from "@/lib/payments"; export const runtime = "nodejs"; export async function POST(request: Request) { // The raw bytes, exactly as sent. Parsed JSON or a decoded string can't be checked. const raw = await request.arrayBuffer(); let event: WebhookEvent; try { event = verifyWebhook(raw, { path: new URL(request.url).pathname, headers: request.headers, secrets: webhookSecrets(), }); } catch (error) { if (error instanceof WebhookError) return Response.json({ error: error.reason }, { status: 400 }); throw error; } try { // Skips an event ID it has already handled. await handleWebhookEvent(hashry(), event); } catch (error) { // Not applied. A non-2xx answer makes Hashry deliver it again. const { status, body } = errorResponse(error); return Response.json(body, { status }); } return Response.json({ ok: true }); } - Add the card form component. It asks your server for a card form, shows it,
and asks your server for the result. It starts with
"use client";because it runs in the browser.components/Pay.tsx "use client"; // components/Pay.tsx: the card form page. // // 1. Ask your server for a card form (embed_url, transaction_uid, status_url, status_token). // 2. Show it with <HashryCheckout>. The page never sees card data or a key. // 3. On "completed", show progress with pollStatus (display only), then ask // your server, which asks Hashry. Only your server's answer counts. // 4. A declined card: the frame asks for another card itself (onDeclined). // Only when every card it allows is declined does "completed" end in // "declined", and "Try again" starts a new attempt. import { useCallback, useEffect, useRef, useState } from "react"; import { HashryCheckout, isHashryPayError, pollStatus, type HashryPayError } from "@hashrytech/pay-react"; type CheckoutSession = { embed_url: string; transaction_uid: string; status_url: string; status_token: string; }; type OrderView = { id: string; status: "unpaid" | "paid" | "review"; payment_status: string | null }; type Phase = "loading" | "card" | "checking" | "paid" | "declined" | "pending" | "review" | "error"; async function post<T>(path: string): Promise<T> { const response = await fetch(path, { method: "POST" }); if (!response.ok) throw new Error(`HTTP ${response.status}`); return (await response.json()) as T; } export function Pay({ orderId }: { orderId: string }) { const [phase, setPhase] = useState<Phase>("loading"); const [session, setSession] = useState<CheckoutSession | null>(null); const [statusText, setStatusText] = useState(""); // A card is going through: no going back to the cart until it ends. const [locked, setLocked] = useState(false); const started = useRef(false); const polling = useRef<AbortController | null>(null); const base = `/api/orders/${encodeURIComponent(orderId)}`; // The answer that counts: your server asks Hashry (refresh). const confirm = useCallback(async () => { const order = await post<OrderView>(`${base}/confirm`); if (order.status === "paid") setPhase("paid"); else if (order.status === "review") setPhase("review"); else if (order.payment_status === "requires_payment_method") setPhase("declined"); else setPhase("pending"); }, [base]); // A first card form, a renewed one after it expired, or a new attempt after // a decline. Your server decides which. Calling it twice is safe. const loadCardForm = useCallback(async () => { setPhase("loading"); try { const { checkout } = await post<{ checkout: CheckoutSession | null }>(`${base}/payment`); if (checkout) { setSession(checkout); setPhase("card"); } else { // No card form to show: paid already, or the payment is being checked. await confirm(); } } catch { setPhase("error"); } }, [base, confirm]); useEffect(() => { // Strict mode runs effects twice in development. One request is enough. if (!started.current) { started.current = true; void loadCardForm(); } return () => polling.current?.abort(); }, [loadCardForm]); async function onCompleted() { // Not proof of payment. The frame only says it reached an outcome. if (!session) return; setLocked(false); setPhase("checking"); const controller = new AbortController(); polling.current = controller; try { // Display only. The status token reads this payment's status and nothing else. await pollStatus({ statusUrl: session.status_url, statusToken: session.status_token, signal: controller.signal, timeoutMs: 60_000, onUpdate: (status) => setStatusText(status.status), }); } catch (error) { if (isHashryPayError(error) && error.code === "aborted") return; // Polling is only a nicety. Ask your server either way. } try { await confirm(); } catch { setPhase("error"); } } function onError(error: HashryPayError) { // error.code is safe to log. The message never holds the embed_url or a token. console.error("Card form error:", error.code); setPhase("error"); } return ( <section> {!locked && <a href="/cart">Back to cart</a>} {phase === "loading" && <p>Loading the card form…</p>} {phase === "checking" && <p>Checking your payment{statusText ? ` (${statusText})` : ""}…</p>} {session && (phase === "card" || phase === "checking") && ( <HashryCheckout embedUrl={session.embed_url} transactionUid={session.transaction_uid} // A card was sent. The bank may be charging it. onProcessing={() => setLocked(true)} // Declined: the frame shows the card form again by itself. Nothing to ask your server. onDeclined={() => setLocked(false)} // Too many tries in a short time. The frame says so and offers "Try again". onPaused={() => setLocked(false)} onCompleted={onCompleted} // The card form ran out before a card was sent. Ask your server for a new one. onExpired={() => void loadCardForm()} onError={onError} /> )} {phase === "paid" && <p>Payment received. Thank you!</p>} {phase === "pending" && <p>Your payment is still being processed. Check your order soon.</p>} {phase === "review" && <p>We are checking your payment. We will email you.</p>} {phase === "declined" && ( <> <p>Every card was declined. Nothing was charged.</p> <button onClick={() => void loadCardForm()}>Try again</button> </> )} {phase === "error" && ( <> <p>Something went wrong. Try again.</p> <button onClick={() => void loadCardForm()}>Try again</button> </> )} </section> ); } - Add the pay page.
app/orders/[id]/pay/page.tsx // app/orders/[id]/pay/page.tsx // components/Pay.tsx is the card form component from the step above. import { Pay } from "@/components/Pay"; export default async function PayPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; return ( <main> <h1>Pay for your order</h1> <Pay orderId={id} /> </main> ); } - Add the build guard. It stops the build if a secret or server code ends up
in browser code. It is a webpack plugin, so the scripts build with webpack.
next.config.mjs // next.config.mjs import { HashryPayGuardPlugin } from "@hashrytech/pay-build-guard/webpack"; /** @type {import("next").NextConfig} */ const nextConfig = { webpack(config) { // Next.js calls this for its client and server builds. The guard checks // only the client build. config.plugins.push(new HashryPayGuardPlugin()); return config; }, }; export default nextConfig;package.json // package.json "scripts": { "dev": "next dev --webpack --experimental-https", "build": "next build --webpack", "start": "next start" } - Start the app with
npm run dev. It runs onhttps://localhost:3000, with a certificate made for your machine.
Check it works
- Open
https://localhost:3000/orders/test-order/pay. - Your browser warns that the connection is not private. That is your dev server's own certificate, so it is safe here. Choose to go on (in Chrome: Advanced, then Proceed).
- The card form appears. Enter the test card
4012000000020071, any expiry date in the future, and123as the security code. - The page says "Checking your payment", then "Payment received. Thank you!". Your server asked Hashry, and Hashry said the payment succeeded. The order is now paid.
The test order can be paid once. To pay again, change test-order to a new ID in lib/shop.ts, restart the server, and open the new address.
Something went wrong? See Troubleshooting.
Webhooks are not set up yet. Hashry can only send them to a public https address. Test and go live shows how to get them on your machine.
How the code keeps payments safe
The code above already does all of this. Keep these rules if you change it.
- Your server sets the price. The amount and currency come from your own order. The page only says which order.
- Mark an order paid only when Hashry says so. That means the status is
succeeded, and theamount,currencyandexternal_referencematch your order. If they don't match, hold the order for review. - Each payment has its own idempotency key. The code uses
order-<id>-pay-1. Sending the same key again is safe: you get the same payment back. The same key with different details gets 409idempotency_conflict. - When you get no answer, don't start a new payment. Repeat the call with the same key, or read the payment. The first call may have worked.
- Never log or store
embed_url. It works like a password for one payment. Pass it to the page and forget it. - A declined card needs nothing from your server. The card form asks for another card by itself, up to 3 cards. See Declines and retries.
Notes
The build guard does not run with Turbopack. Next.js 16 builds with
Turbopack by default. The guard is a webpack plugin, so keep --webpack in your scripts.
- The routes set
runtime = "nodejs".@hashrytech/pay-nodeneedsnode:crypto, so don't move them to the Edge runtime. - The webhook route checks the request path. If a proxy changes the path, pass the path of the URL you gave Hashry as a fixed string.
- In production, set
PUBLIC_BASE_URLto your live https address, and add it to the live allowed origins.