React component

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}/payment answers { checkout }: the four checkout values, or null when there is no card form to show.
  • POST /api/orders/{id}/confirm asks Hashry for the outcome, then answers { id, status, payment_status }.

Install

Terminal
# React 18 or 19
npm install @hashrytech/pay-react

# The build guard (Vite or webpack)
npm install --save-dev @hashrytech/pay-build-guard

The 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
// 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
// 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
// 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_url on 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 minHeight and maxHeight.
  • 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 embedUrl or transactionUid changes.

Props: embedUrl (required) and transactionUid (recommended), then the callbacks below.

ReactSvelteWhen it fires
onReadyonreadyThe card form is on screen. Fires again after a declined card, when the form is back.
onResizeonresizeThe frame height changed. The new height is already applied.
onProcessingonprocessingA card was sent. Stop the shopper from changing the order.
onChallengeonchallengeThe bank's 3-D Secure check is running inside the frame.
onCompletedoncompletedThe payment reached an outcome, good or bad. Not proof of payment: ask your server.
onExpiredonexpiredThe session ran out before a card was sent. Ask your server for a new card form.
onDeclinedondeclinedA card was declined. Nothing was charged. The card form asks for another card by itself: keep it, and unlock the page.
onPausedonpausedToo 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.
onCancelledoncancelledOlder gateway builds only. Nothing was charged.
onFallbackonfallbackThe shopper chose the full payment page. The window is about to leave.
onErroronerrorSomething 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.

SettingDefaultMeaning
minHeight320Smallest frame height, in px.
maxHeight4000Largest frame height, in px.
loadTimeoutMs15000How long to wait for the form. 0 turns the timeout off.
showFallbacktrueShow "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 }.

pollStatus
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.
OptionDefaultMeaning
statusUrlrequiredThe status_url your server got.
statusTokenrequiredThe status_token your server got. It starts with hst_.
intervalMs2000Wait between reads. At least 1000.
timeoutMs15 minutesGive up with code status_timeout. 0 turns it off.
signalAn AbortSignal. Stops polling with code aborted.
onUpdateCalled after every read.
Don't:

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: HashryPayGuardPlugin in next.config.mjs, webpack builds only. See Next.js.
  • Any other webpack 5 build:
webpack.config.js
// webpack.config.js (webpack 5)
import { HashryPayGuardPlugin } from "@hashrytech/pay-build-guard/webpack";

export default {
  // ...your settings
  plugins: [new HashryPayGuardPlugin()],
};
RuleWhat fails the build
secret-keyA 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-sdkHashry server code in the browser bundle: @hashrytech/pay-node, or files from the Python SDK hashry_pay.
secret-env-nameA 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.

Next: build it for your stack

Back to top

© 2026 Hashry Solutions Limited. All rights reserved.