Developers · Hashry Pay
Hashry Pay React component
@hashrytech/pay-react shows the card form on a React 18 or 19 page.
The Next.js, Express + React and Python pages already use it, so you don't need this page to follow them. Read it to use the component in another app, or to change how it behaves.
What your page calls
The browser packages have no key and no API client. They show the card form and read the payment status. Your page talks only to your own server, through two routes. Each stack page builds them:
POST /api/orders/{id}/paymentanswers{ checkout }: the four checkout values, ornullwhen there is no card form to show.POST /api/orders/{id}/confirmasks Hashry for the outcome, then answers{ id, status, payment_status }.
Install
# React 18 or 19
npm install @hashrytech/pay-react
# The build guard (Vite or webpack)
npm install --save-dev @hashrytech/pay-build-guardThe Pay component
This component loads the card form, shows progress, and asks your server for the result. It handles an expired card form and a declined card. Only your server says the order is paid.
// Pay.tsx: the card form page. In Next.js, add "use client"; as the first line.
//
// 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>
);
}Use it as <Pay orderId="…" /> on your pay page. In Next.js, the
file must start with "use client";.
With Vite, add https and the build guard to your Vite config. Next.js adds the build guard in next.config.mjs instead: see Next.js.
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import basicSsl from "@vitejs/plugin-basic-ssl";
import { hashryPayGuard } from "@hashrytech/pay-build-guard/vite";
export default defineConfig({
plugins: [
react(),
// The card form shows only in https pages, even on localhost.
// This gives the dev server a self-signed certificate.
basicSsl(),
// Fails the build if a Hashry secret or server code ends up in the page.
hashryPayGuard(),
],
server: {
// The port is part of the origin you allow in the Hashry admin. Keep it fixed.
port: 5173,
strictPort: true,
// Your own server (Express, FastAPI, ...), not Hashry's API.
proxy: { "/api": "http://localhost:8000" },
},
});The hook, for your own layout
useHashryCheckout takes the same options as the component. It gives you a ref to put on your own element, plus openFullPage().
// CardForm.tsx: the same card form, in an element you own.
import { useHashryCheckout } from "@hashrytech/pay-react";
export function CardForm(props: { embedUrl: string; transactionUid: string; onCompleted: () => void }) {
// Takes the same options as mount() in @hashrytech/pay.
const { ref, openFullPage } = useHashryCheckout(props);
return (
<div className="card-form">
<div ref={ref} />
{/* Moves the payment to the full Hashry Pay page. False when there is no fallback yet. */}
<button type="button" onClick={() => openFullPage()}>
Open the payment page in full
</button>
</div>
);
}Component reference
The component does the fiddly parts for you:
- It frames only an
embed_urlon a Hashry Pay host. There is no setting to add other hosts. - It checks every message from the frame: the sender, the frame's own window, the contract version and the transaction.
- It resizes the frame to fit, between
minHeightandmaxHeight. - It offers "Open the payment page in full" during the bank check, when that helps. The shopper
then comes back through your
return_url. - It mounts a new frame only when
embedUrlortransactionUidchanges.
Props: embedUrl (required) and transactionUid (recommended), then the callbacks
below.
| React | Svelte | When it fires |
|---|---|---|
onReady | onready | The card form is on screen. Fires again after a declined card, when the form is back. |
onResize | onresize | The frame height changed. The new height is already applied. |
onProcessing | onprocessing | A card was sent. Stop the shopper from changing the order. |
onChallenge | onchallenge | The bank's 3-D Secure check is running inside the frame. |
onCompleted | oncompleted | The payment reached an outcome, good or bad. Not proof of payment: ask your server. |
onExpired | onexpired | The session ran out before a card was sent. Ask your server for a new card form. |
onDeclined | ondeclined | A card was declined. Nothing was charged. The card form asks for another card by itself: keep it, and unlock the page. |
onPaused | onpaused | Too many tries in a short time. Nothing was charged. Keep the card form and unlock the page. Its "Try again" brings the form back after a few minutes. |
onCancelled | oncancelled | Older gateway builds only. Nothing was charged. |
onFallback | onfallback | The shopper chose the full payment page. The window is about to leave. |
onError | onerror | Something failed after mount, for example code load_timeout. |
Settings. In React, pass them in options; in Svelte, as props. React also takes className and style; Svelte takes class.
| Setting | Default | Meaning |
|---|---|---|
minHeight | 320 | Smallest frame height, in px. |
maxHeight | 4000 | Largest frame height, in px. |
loadTimeoutMs | 15000 | How long to wait for the form. 0 turns the timeout off. |
showFallback | true | Show "Open the payment page in full" under the frame when it helps. |
fallbackLabel | "Having trouble? Open the payment page in full" | The text of that button. |
A React ref (HashryCheckoutRef), or bind:this in Svelte,
gives you openFullPage(). Without a framework, @hashrytech/pay has mount(container, options), which returns { openFullPage(), destroy() }.
pollStatus
pollStatus reads GET /v1/public/payment-intents/{uid}/status with the X-Hashry-Status-Token header until the payment stops changing: succeeded, requires_payment_method, expired, canceled or payment_review. It retries network errors, 429 and 5xx with a growing wait. It
resolves with { status, amount, currency, updated_at }.
import { pollStatus } from "@hashrytech/pay-react"; // or "@hashrytech/pay-svelte"
const result = await pollStatus({
statusUrl: session.status_url,
statusToken: session.status_token, // starts with hst_
timeoutMs: 60_000,
onUpdate: (status) => console.log(status.status),
});
// result: { status, amount, currency, updated_at }. For display only.| Option | Default | Meaning |
|---|---|---|
statusUrl | required | The status_url your server got. |
statusToken | required | The status_token your server got. It starts with hst_. |
intervalMs | 2000 | Wait between reads. At least 1000. |
timeoutMs | 15 minutes | Give up with code status_timeout. 0 turns it off. |
signal | An AbortSignal. Stops polling with code aborted. | |
onUpdate | Called after every read. |
Never treat onCompleted or pollStatus as proof of payment. Only your
server's refresh, or a verified webhook, says a payment succeeded.
Build guard
@hashrytech/pay-build-guard fails your build when a Hashry secret, or Hashry server code,
ends up in browser code. It checks browser builds only and skips server builds. In the Vite dev server
it only prints warnings.
- Vite:
hashryPayGuard()from@hashrytech/pay-build-guard/vite. See the React Vite config. - SvelteKit: next to
sveltekit(). See SvelteKit. - Next.js:
HashryPayGuardPlugininnext.config.mjs, webpack builds only. See Next.js. - Any other webpack 5 build:
// webpack.config.js (webpack 5)
import { HashryPayGuardPlugin } from "@hashrytech/pay-build-guard/webpack";
export default {
// ...your settings
plugins: [new HashryPayGuardPlugin()],
};| Rule | What fails the build |
|---|---|
secret-key | A real secret in the output: hsk_live_, hsk_test_, hwhsec_live_ or hwhsec_test_ followed by a random value. A bare prefix, as in docs, does not count. |
server-sdk | Hashry server code in the browser bundle: @hashrytech/pay-node, or files from the Python SDK hashry_pay. |
secret-env-name | A setting named like HASHRY_CLIENT_SECRET or HASHRY_WEBHOOK_SECRET, read in browser code, given a public prefix (VITE_, NEXT_PUBLIC_, ...) or used as a define key. |
The error names the file and the rule. It never prints the secret. Options: mode: "error" (default) or "warn", and allow: (finding) => boolean for a false positive. The guard is a safety net: it can't
see a secret that your page fetches at run time. If it finds a real secret, rotate the key, even if
that build was never deployed.