Add card payments with a Python server and a React page built with Vite. The server uses the hashry-pay SDK with FastAPI. Every file you need is on this page, in order. Using Django
or Flask? See the notes at the end.
What you'll build
Two parts. The Python server runs on port 8000. The React page runs on https://localhost:5173, and Vite passes its /api calls to the server.
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:
Files
.env your settings
app.py the FastAPI server: the Hashry client and the four routes
shop-page/ the page, a React + Vite app
src/Pay.tsx the card form component
src/main.tsx shows the pay page
vite.config.ts https, the build guard, and /api to the Python server
The 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:5173 added to both lists on the admin's Test Sandbox tab: Allowed return origins and Allowed embed origins.
Add your settings. Create .env next to app.py.
Put in your test key. PUBLIC_BASE_URL is the address of the page, not of the
Python server: shoppers open the page. .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_...
# Not secret: the https address shoppers open. It must be on Allowed embed# origins and Allowed return origins in the Hashry admin.PUBLIC_BASE_URL=https://localhost:5173
Add the server. This one file has the Hashry client, your orders and the
four routes. It keeps orders in memory and has one test order, test-order.
Change JMD to your store's currency. Later, swap in your real database. app.py
"""app.py: a FastAPI server that takes payments with the Hashry Pay card form.Run it with: uvicorn app:app --port 8000 --env-file .env"""from __future__ import annotations
import logging
import os
import uuid
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from typing import Any
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse
from hashry_pay import ApiConnectionError, ApiError, AsyncHashry, WebhookError, webhooks
from hashry_pay.types import PaymentIntent
log = logging.getLogger("shop")
# The client checks the key: a test key (hsk_test_) works only with "sandbox".
hashry = AsyncHashry(
client_id=os.environ["HASHRY_CLIENT_ID"],
client_secret=os.environ["HASHRY_CLIENT_SECRET"],
environment=os.environ.get("HASHRY_ENVIRONMENT", "sandbox"),
)
# Your shop's https origin, where shoppers see the page.SITE = os.environ["PUBLIC_BASE_URL"].rstrip("/")
# During a secret rotation, set both. The new one goes first.WEBHOOK_SECRETS = [
secret
for secret in (os.environ.get("HASHRY_WEBHOOK_SECRET"), os.environ.get("HASHRY_WEBHOOK_SECRET_PREVIOUS"))
if secret
]
# A card form can still be shown (or renewed) in these states.OPEN = {"awaiting_customer", "redirect_issued"}
# ---------------------------------------------------------------- your database# In memory, only for trying things out. Use your real database.@dataclassclass Order:
id: str
email: str
amount: str # set by your server, for example "2500.00"
currency: str
status: str = "unpaid" # unpaid | paid | review. Only your server decides "paid".
payment_status: str | None = None
payment_intent_uid: str | None = None
payment_number: int = 1 # goes up when a payment expired and a new one is neededORDERS: dict[str, Order] = {}
HANDLED_EVENTS: set[str] = set()
# 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["test-order"] = Order(id="test-order", email="shopper@example.com", amount="2500.00", currency="JMD")
def create_order(email: str, amount: str, currency: str) -> Order:
order = Order(id=uuid.uuid4().hex, email=email, amount=amount, currency=currency)
ORDERS[order.id] = order
return order
def load(order_id: str) -> Order:
order = ORDERS.get(order_id)
if order is None:
raise HTTPException(status_code=404, detail="order_not_found")
return order
def view(order: Order) -> dict[str, Any]:
"""What the browser may see about an order."""return {"id": order.id, "status": order.status, "payment_status": order.payment_status}
def record(order: Order, intent: PaymentIntent) -> None:
"""Save what Hashry says. Paid only for this order's exact amount, currency and reference."""
order.payment_status = intent.status
if order.status == "paid":
returnif intent.status == "succeeded":
try:
same_amount = Decimal(intent.amount) == Decimal(order.amount)
except InvalidOperation:
same_amount = Falseif same_amount and intent.currency == order.currency and intent.external_reference == order.id:
order.status = "paid"else:
log.warning("Payment %s does not match order %s", intent.uid, order.id)
order.status = "review"elif intent.status == "payment_review":
order.status = "review"# ---------------------------------------------------------------------- the app@asynccontextmanagerasync def lifespan(_app: FastAPI) -> AsyncIterator[None]:
yieldawait hashry.aclose()
app = FastAPI(lifespan=lifespan)
@app.exception_handler(ApiConnectionError)
async def hashry_unreachable(_request: Request, _exc: ApiConnectionError) -> JSONResponse:
# The call may have reached Hashry. Calling the same route again is safe:# it sends the same idempotency key.return JSONResponse({"error": "hashry_unreachable", "retry": True}, status_code=503)
@app.exception_handler(ApiError)
async def hashry_refused(_request: Request, exc: ApiError) -> JSONResponse:
# The SDK masks secrets and embed URLs in errors, so this is safe to log.
log.warning("Hashry API error: %r", exc)
return JSONResponse({"error": exc.code, "retry": exc.outcome_unknown}, status_code=502)
async def card_form(order: Order) -> PaymentIntent | None:
"""Start, renew or retry the payment. None when no card form should be shown."""if order.payment_intent_uid:
current = await hashry.payment_intents.retrieve(order.payment_intent_uid)
latest = max((attempt.attempt_number for attempt in current.attempts), default=1)
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.return await hashry.payment_intents.create_attempt(
current.uid,
idempotency_key=f"order-{order.id}-attempt-{latest + 1}",
presentation="embedded",
embed_origin=SITE,
)
if current.status in OPEN and latest > 1:
# A retry's card form expired unused. Its own key again renews it.return await hashry.payment_intents.create_attempt(
current.uid,
idempotency_key=f"order-{order.id}-attempt-{latest}",
presentation="embedded",
embed_origin=SITE,
)
if current.status in {"expired", "canceled"}:
# Over, and nothing was charged. Start a new payment with a new key.
order.payment_intent_uid = None
order.payment_number += 1elif current.status not in OPEN:
# succeeded, processing or payment_review: no new card form.
record(order, current)
return None# 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.payment_intents.create(
amount=order.amount, # a string or Decimal, never a floatcurrency=order.currency,
description=f"Order {order.id}",
external_reference=order.id,
payer={"email": order.email},
return_url=f"{SITE}/api/orders/{order.id}/return",
cancel_url=f"{SITE}/cart",
presentation="embedded",
embed_origin=SITE,
idempotency_key=f"order-{order.id}-pay-{order.payment_number}",
)
order.payment_intent_uid = intent.uid
order.payment_status = intent.status
return intent
@app.post("/api/orders/{order_id}/payment")
async def start_payment(order_id: str) -> dict[str, Any]:
"""Gives the page embed_url, transaction_uid, status_url and status_token. Nothing else."""
order = load(order_id)
intent = await card_form(order) if order.status == "unpaid" else Noneif intent is not None and not (
intent.embed_url and intent.transaction_uid and intent.status_url and intent.status_token
):
# This card form was already used. Ask the gateway how it ended.
record(order, await hashry.payment_intents.refresh(intent.uid))
intent = Noneif intent is None:
return {"checkout": None}
return {
"checkout": {
"embed_url": intent.embed_url,
"transaction_uid": intent.transaction_uid,
"status_url": intent.status_url,
"status_token": intent.status_token,
}
}
async def confirm(order: Order) -> Order:
"""refresh asks the gateway first. Use it before you fulfil an order."""if order.payment_intent_uid and order.status != "paid":
record(order, await hashry.payment_intents.refresh(order.payment_intent_uid))
return order
@app.post("/api/orders/{order_id}/confirm")
async def confirm_order(order_id: str) -> dict[str, Any]:
return view(await confirm(load(order_id)))
@app.get("/api/orders/{order_id}/return")
async def payment_return(order_id: str) -> RedirectResponse:
"""The return_url. Ask Hashry, then send the shopper back to the pay page. It shows the result."""
order = load(order_id)
try:
await confirm(order)
except (ApiError, ApiConnectionError) as exc:
# The pay page checks again, and the webhook also brings the answer.
log.warning("Hashry API error: %r", exc)
return RedirectResponse(f"{SITE}/orders/{order.id}/pay", status_code=303)
@app.post("/api/webhooks/hashry")
async def hashry_webhook(request: Request) -> JSONResponse:
# The raw bytes, exactly as sent. Parsed and re-serialised JSON can't be checked.
raw = await request.body()
try:
event = webhooks.verify(raw, path=request.url.path, headers=request.headers, secrets=WEBHOOK_SECRETS)
except WebhookError as exc:
return JSONResponse({"error": exc.reason}, status_code=400)
if event.id in HANDLED_EVENTS:
return JSONResponse({"ok": True}) # already appliedif event.payment_intent_uid:
# The event is a cue. The payment read from Hashry is the truth.
intent = await hashry.payment_intents.retrieve(event.payment_intent_uid)
order = ORDERS.get(intent.external_reference or "")
if order is not None:
record(order, intent)
# In a real database, save the event ID and the order change in one transaction.HANDLED_EVENTS.add(event.id)
return JSONResponse({"ok": True})
Create the page. A React + Vite app, in its own folder: Terminal
Using Svelte for the page? Follow the Svelte component page for the next three steps.
Add the card form component. It asks your server for a card form, shows it,
and asks your server for the result. src/Pay.tsx
// src/Pay.tsx: 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.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") && (
<HashryCheckoutembedUrl={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>
);
}
Add the pay page. Replace src/main.tsx with this. It shows the
card form for the order in the address. src/main.tsx
// src/main.tsx: shows the pay page for /orders/<id>/pay.// Vite serves index.html for every path, so this runs on that address.import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { Pay } from "./Pay";
// /orders/<id>/pay gives <id>. If your app has a router, use it instead.const orderId = decodeURIComponent(location.pathname.split("/")[2] ?? "");
createRoot(document.getElementById("root")!).render(
<StrictMode>
<h1>Pay for your order</h1>
<Pay orderId={orderId} />
</StrictMode>,
);
Set up Vite. Replace vite.config.ts with this. It serves the
page over https, adds the build guard, and passes /api to the Python server. vite.config.ts
// vite.config.tsimport { 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" },
},});
Start both, each in its own terminal: Terminal
# Terminal 1: the server, on http://localhost:8000uvicorn app:app --port 8000 --env-file .env# Terminal 2: the page, on https://localhost:5173cd shop-pagenpx vite
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, and 123 as 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 app.py, restart the server, and open the new address.
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 the amount, currency and external_reference match 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 409 idempotency_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
Amounts are strings or Decimal, such as "2500.00". Never a float.
Results are typed objects. Their raw attribute holds the full answer, so new API
fields are there before the SDK models them.
Leave base_url unset. It accepts only Hashry's API or localhost,
so a wrong setting can't send your key to another server.
A test key works only with environment="sandbox", and a live key only with "live". The client refuses the wrong pair.
In production, serve the built page and the API from one https address. Set PUBLIC_BASE_URL to it, and add it to the live allowed origins.
Django and Flask
The steps are the same. Use the sync client, Hashry: it has the same methods,
without await. For webhooks, pass the raw body, the path and the headers to webhooks.verify.
Django and Flask
import os
from hashry_pay import Hashry, WebhookError, webhooks
# The sync client has the same methods as AsyncHashry, without await.
hashry = Hashry(
client_id=os.environ["HASHRY_CLIENT_ID"],
client_secret=os.environ["HASHRY_CLIENT_SECRET"],
environment=os.environ.get("HASHRY_ENVIRONMENT", "sandbox"),
)
WEBHOOK_SECRETS = [
secret
for secret in (os.environ.get("HASHRY_WEBHOOK_SECRET"), os.environ.get("HASHRY_WEBHOOK_SECRET_PREVIOUS"))
if secret
]
# Django: views.py. Hashry signs the request, so the view is exempt from CSRF.from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
@csrf_exempt@require_POSTdef hashry_webhook(request):
try:
event = webhooks.verify(request.body, path=request.path, headers=request.headers, secrets=WEBHOOK_SECRETS)
except WebhookError as exc:
return JsonResponse({"error": exc.reason}, status=400)
... # dedupe by event.id, read the payment, update the order (as in the FastAPI example)return JsonResponse({"ok": True})
# Flaskfrom flask import Flask, request
app = Flask(__name__)
@app.post("/api/webhooks/hashry")
def flask_hashry_webhook():
try:
event = webhooks.verify(request.get_data(), path=request.path, headers=request.headers, secrets=WEBHOOK_SECRETS)
except WebhookError as exc:
return {"error": exc.reason}, 400... # dedupe by event.id, read the payment, update the order (as in the FastAPI example)return {"ok": True}