Developers · Hashry Pay
Hashry Pay for SvelteKit
Add card payments to a SvelteKit app. The +server.ts routes hold your key. A Svelte
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 your settings
src/lib/server/shop.ts your orders (a stand-in database)
src/lib/server/payments.ts the payment steps
src/lib/server/hashry.ts the Hashry client
src/routes/api/orders/[id]/payment/+server.ts starts the payment
src/routes/api/orders/[id]/confirm/+server.ts confirms it with Hashry
src/routes/api/orders/[id]/return/+server.ts the return_url
src/routes/api/webhooks/hashry/+server.ts the webhook URL
src/lib/Pay.svelte the card form component
src/routes/orders/[id]/pay/+page.svelte the pay page
vite.config.ts https and 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:5173added to both lists on the admin's Test Sandbox tab: Allowed return origins and Allowed embed origins.- Node 22.12 or newer.
- A SvelteKit app with TypeScript. To start a new one (pick the minimal template and
TypeScript):
Terminal npx sv create my-shop cd my-shop
Step by step
- Install the packages.
Terminal npm install @hashrytech/pay-node @hashrytech/pay-svelte npm install --save-dev @hashrytech/pay-build-guard @vitejs/plugin-basic-ssl - Add your settings. Create
.envin the project folder. Put in your test key. You don't need a site address: SvelteKit takes it from the request..env # 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_... # No site address here: SvelteKit takes it from the request (url.origin). - 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.src/lib/server/shop.ts // src/lib/server/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.
src/lib/server/payments.ts // src/lib/server/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. SvelteKit never puts
src/lib/serverin browser code.src/lib/server/hashry.ts // src/lib/server/hashry.ts // SvelteKit never bundles src/lib/server into browser code. // // Secrets come from $env/dynamic/private, read at run time. Not from // $env/static/private: static values are written into the server build, so the // secret would sit in build/ and in any image made from it. import { env } from "$env/dynamic/private"; import { Hashry, type Environment } from "@hashrytech/pay-node"; let client: Hashry | null = null; // Created on first use, so `vite build` works without the secrets set. export function hashry(): Hashry { client ??= new Hashry({ clientId: env.HASHRY_CLIENT_ID ?? "", clientSecret: env.HASHRY_CLIENT_SECRET ?? "", // The SDK refuses anything but "sandbox" or "live", and a key for the other mode. environment: (env.HASHRY_ENVIRONMENT || "sandbox") as Environment, }); return client; } // During a secret rotation, set both. The new one goes first. export function webhookSecrets(): string[] { return [env.HASHRY_WEBHOOK_SECRET, env.HASHRY_WEBHOOK_SECRET_PREVIOUS] .map((value) => value?.trim() ?? "") .filter(Boolean); } - Add the four routes.
src/routes/api/orders/[id]/payment/+server.ts // src/routes/api/orders/[id]/payment/+server.ts // Start the payment, renew an expired card form, or retry after a decline. import { error, json } from "@sveltejs/kit"; import { hashry } from "$lib/server/hashry"; import { errorResponse, startPayment } from "$lib/server/payments"; import { getOrder } from "$lib/server/shop"; import type { RequestHandler } from "./$types"; export const POST: RequestHandler = async ({ params, url }) => { const order = await getOrder(params.id); if (!order) error(404, "Order not found"); try { // url.origin is your site's https origin. With adapter-node, set ORIGIN. return json({ checkout: await startPayment(hashry(), order, url.origin) }); } catch (e) { const { status, body } = errorResponse(e); return json(body, { status }); } };src/routes/api/orders/[id]/confirm/+server.ts // src/routes/api/orders/[id]/confirm/+server.ts // Confirm: ask Hashry now (refresh), then answer with this server's view of the order. import { error, json } from "@sveltejs/kit"; import { hashry } from "$lib/server/hashry"; import { confirmPayment, errorResponse, orderView } from "$lib/server/payments"; import { getOrder } from "$lib/server/shop"; import type { RequestHandler } from "./$types"; export const POST: RequestHandler = async ({ params }) => { const order = await getOrder(params.id); if (!order) error(404, "Order not found"); try { return json(orderView(await confirmPayment(hashry(), order))); } catch (e) { const { status, body } = errorResponse(e); return json(body, { status }); } };src/routes/api/orders/[id]/return/+server.ts // src/routes/api/orders/[id]/return/+server.ts // The return_url. Ask Hashry, then send the shopper back to the pay page. It shows the result. import { error, redirect } from "@sveltejs/kit"; import { hashry } from "$lib/server/hashry"; import { confirmPayment, isHashryCallError } from "$lib/server/payments"; import { getOrder } from "$lib/server/shop"; import type { RequestHandler } from "./$types"; export const GET: RequestHandler = async ({ params }) => { const order = await getOrder(params.id); if (!order) error(404, "Order not found"); try { await confirmPayment(hashry(), order); } catch (e) { // The pay page checks again, and the webhook also brings the answer. if (!isHashryCallError(e)) throw e; console.warn(e.message); } redirect(303, `/orders/${encodeURIComponent(order.id)}/pay`); };src/routes/api/webhooks/hashry/+server.ts // src/routes/api/webhooks/hashry/+server.ts // Give Hashry this URL: https://<your host>/api/webhooks/hashry import { json } from "@sveltejs/kit"; import { WebhookError, verifyWebhook, type WebhookEvent } from "@hashrytech/pay-node"; import { hashry, webhookSecrets } from "$lib/server/hashry"; import { errorResponse, handleWebhookEvent } from "$lib/server/payments"; import type { RequestHandler } from "./$types"; export const POST: RequestHandler = async ({ request, url }) => { // 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: url.pathname, headers: request.headers, secrets: webhookSecrets() }); } catch (e) { if (e instanceof WebhookError) return json({ error: e.reason }, { status: 400 }); throw e; } try { // Skips an event ID it has already handled. await handleWebhookEvent(hashry(), event); } catch (e) { // Not applied. A non-2xx answer makes Hashry deliver it again. const { status, body } = errorResponse(e); return json(body, { status }); } return 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.
src/lib/Pay.svelte <!-- src/lib/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} - Add the pay page.
src/routes/orders/[id]/pay/+page.svelte <!-- src/routes/orders/[id]/pay/+page.svelte --> <!-- src/lib/Pay.svelte is the card form component from the step above. --> <script lang="ts"> import { page } from "$app/state"; import Pay from "$lib/Pay.svelte"; </script> <h1>Pay for your order</h1> <Pay orderId={page.params.id ?? ""} /> - Set up Vite. Replace
vite.config.tswith this. It serves the app over https on a fixed port, and adds the build guard.vite.config.ts // vite.config.ts import { sveltekit } from "@sveltejs/kit/vite"; import basicSsl from "@vitejs/plugin-basic-ssl"; import { hashryPayGuard } from "@hashrytech/pay-build-guard/vite"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [ sveltekit(), // The card form shows only in https pages, even on localhost. basicSsl(), // Checks the browser build only. src/lib/server and +server.ts are not checked. hashryPayGuard(), ], // The port is part of the origin you allow in the Hashry admin. Keep it fixed. server: { port: 5173, strictPort: true }, }); - Start the app with
npm run dev. It runs onhttps://localhost:5173.
Check it works
- Open
https://localhost:5173/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 src/lib/server/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
Use $env/dynamic/private, not $env/static/private. Static values are written into the server build. The secret would then sit in build/ and in every image made from it. Dynamic values are read when the server runs.
- The routes use
url.originas your site's address. Withadapter-node, setORIGINin production, or the address may be wrong. - Add your live https address to the live allowed origins.