Python

Developers · Hashry Pay

Hashry Pay for Python

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.

RouteWhat it does
POST /api/orders/{id}/paymentStarts 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}/confirmAsks Hashry for the latest outcome (refresh) and updates the order. Answers { id, status, payment_status }.
GET /api/orders/{id}/returnThe 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/hashryThe 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.
  • Python 3.11 or newer, for the server.
  • Node 22 or newer, for the page.

Step by step

  1. Install the server packages.
    Terminal
    pip install hashry-pay fastapi "uvicorn[standard]"
  2. 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
  3. 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.
    
    
    @dataclass
    class 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 needed
    
    
    ORDERS: 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":
            return
        if intent.status == "succeeded":
            try:
                same_amount = Decimal(intent.amount) == Decimal(order.amount)
            except InvalidOperation:
                same_amount = False
            if 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
    
    
    @asynccontextmanager
    async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
        yield
        await 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 += 1
            elif 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 float
            currency=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 None
        if 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 = None
        if 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 applied
        if 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})
  4. Create the page. A React + Vite app, in its own folder:
    Terminal
    npm create vite@latest shop-page -- --template react-ts
    cd shop-page
    npm install @hashrytech/pay-react
    npm install --save-dev @hashrytech/pay-build-guard @vitejs/plugin-basic-ssl
    Using Svelte for the page? Follow the Svelte component page for the next three steps.
  5. 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") && (
            <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>
      );
    }
  6. 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>,
    );
  7. 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.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" },
      },
    });
  8. Start both, each in its own terminal:
    Terminal
    # Terminal 1: the server, on http://localhost:8000
    uvicorn app:app --port 8000 --env-file .env
    
    # Terminal 2: the page, on https://localhost:5173
    cd shop-page
    npx vite

Check it works

  1. Open https://localhost:5173/orders/test-order/pay.
  2. 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).
  3. The card form appears. Enter the test card 4012000000020071, any expiry date in the future, and 123 as the security code.
  4. 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.

Something went wrong? See Troubleshooting.

Note:

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_POST
def 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})


# Flask
from 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}

Next: test it

Back to top

© 2026 Hashry Solutions Limited. All rights reserved.