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}/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
# Svelte 5
npm install @hashrytech/pay-svelte
# 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.
Svelte callback props are lowercase: oncompleted={...}, onexpired, onerror.
<!--
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: 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_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 }.
The example is on the React page. It is the same
in Svelte: import pollStatus from @hashrytech/pay-svelte.
| 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: see the webpack config on the React page.
| 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.