Svelte component

Developers · Hashry Pay

Hashry Pay Svelte component

@hashrytech/pay-svelte shows the card form on a Svelte 5 page.

The SvelteKit page already uses it, so you don't need this page to follow it. Read it to use the component with another server, such as Python, 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
# Svelte 5
npm install @hashrytech/pay-svelte

# 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.

Svelte callback props are lowercase: oncompleted={...}, onexpired, onerror.

Pay.svelte
<!--
  Pay.svelte: 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.
-->
<script lang="ts">
  import { onMount } from "svelte";
  import { HashryCheckout, isHashryPayError, pollStatus, type HashryPayError } from "@hashrytech/pay-svelte";

  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";

  let { orderId }: { orderId: string } = $props();

  let phase = $state<Phase>("loading");
  let session = $state<CheckoutSession | null>(null);
  let statusText = $state("");
  // A card is going through: no going back to the cart until it ends.
  let locked = $state(false);
  let polling: AbortController | null = null;

  const base = $derived(`/api/orders/${encodeURIComponent(orderId)}`);

  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;
  }

  // The answer that counts: your server asks Hashry (refresh).
  async function confirm() {
    const order = await post<OrderView>(`${base}/confirm`);
    if (order.status === "paid") phase = "paid";
    else if (order.status === "review") phase = "review";
    else if (order.payment_status === "requires_payment_method") phase = "declined";
    else phase = "pending";
  }

  // 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.
  async function loadCardForm() {
    phase = "loading";
    try {
      const { checkout } = await post<{ checkout: CheckoutSession | null }>(`${base}/payment`);
      if (checkout) {
        session = checkout;
        phase = "card";
      } else {
        // No card form to show: paid already, or the payment is being checked.
        await confirm();
      }
    } catch {
      phase = "error";
    }
  }

  async function completed() {
    // Not proof of payment. The frame only says it reached an outcome.
    if (!session) return;
    locked = false;
    phase = "checking";
    const controller = new AbortController();
    polling = 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) => (statusText = 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 {
      phase = "error";
    }
  }

  function failed(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);
    phase = "error";
  }

  onMount(() => {
    void loadCardForm();
    return () => polling?.abort();
  });
</script>

{#if !locked}
  <a href="/cart">Back to cart</a>
{/if}

{#if phase === "loading"}
  <p>Loading the card form…</p>
{:else if phase === "checking"}
  <p>Checking your payment{statusText ? ` (${statusText})` : ""}…</p>
{/if}

{#if session && (phase === "card" || phase === "checking")}
  <!-- Svelte callback props are lowercase. ondeclined: the frame shows the card
       form again by itself, so there is nothing to ask your server. -->
  <HashryCheckout
    embedUrl={session.embed_url}
    transactionUid={session.transaction_uid}
    onprocessing={() => (locked = true)}
    ondeclined={() => (locked = false)}
    onpaused={() => (locked = false)}
    oncompleted={completed}
    onexpired={() => void loadCardForm()}
    onerror={failed}
  />
{/if}

{#if phase === "paid"}
  <p>Payment received. Thank you!</p>
{:else if phase === "pending"}
  <p>Your payment is still being processed. Check your order soon.</p>
{:else if phase === "review"}
  <p>We are checking your payment. We will email you.</p>
{:else if phase === "declined"}
  <p>Every card was declined. Nothing was charged.</p>
  <button onclick={() => void loadCardForm()}>Try again</button>
{:else if phase === "error"}
  <p>Something went wrong. Try again.</p>
  <button onclick={() => void loadCardForm()}>Try again</button>
{/if}

Svelte without SvelteKit

In a Svelte + Vite app (npm create vite@latest -- --template svelte-ts), put the component in src/Pay.svelte. Then show it from src/main.ts:

src/main.ts
// src/main.ts: shows the pay page for /orders/<id>/pay.
import { mount } from "svelte";
import Pay from "./Pay.svelte";

// /orders/<id>/pay gives <id>. If your app has a router, use it instead.
const orderId = decodeURIComponent(location.pathname.split("/")[2] ?? "");

mount(Pay, { target: document.getElementById("app")!, props: { orderId } });

Use the React Vite config, with svelte() from @sveltejs/vite-plugin-svelte in place of react(). In SvelteKit, the build guard goes next to sveltekit(): see SvelteKit.

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 }.

The example is on the React page. It is the same in Svelte: import pollStatus from @hashrytech/pay-svelte.

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: see the webpack config on the React page.
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.