Python · FastAPI · httpx

An async FastAPI payment flow built for exactness and retries

Use Decimal-safe boundaries, authenticated raw webhooks, transaction-level idempotency, and controlled reconciliation for a UPI integration that remains correct under duplicate delivery and network failure.

Architecture

Keep external I/O outside your payment truth

01

Trusted input

Authenticate the customer and calculate the cart from server catalogue data.

02

Stable identity

Persist one client_txn_id before calling the gateway and reuse it after uncertainty.

03

Verified event

Authenticate exact bytes, then validate the parsed event against the locked order.

04

Durable effect

Commit payment plus outbox atomically; let a worker perform fulfilment after commit.

Step 1 · configuration and money

Fail fast on missing secrets and never calculate with float

Environment files are suitable for local development; use your platform’s encrypted secret store in hosted environments. Keep integer paise in storage and convert through Decimal only where the API accepts rupees.

# requirements
# fastapi, httpx, pydantic-settings, uvicorn

from decimal import Decimal

import httpx
from pydantic import AnyHttpUrl, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        extra="ignore",
    )

    vg_base_url: AnyHttpUrl
    vg_api_key: SecretStr
    vg_webhook_secret: SecretStr
    public_store_url: AnyHttpUrl


settings = Settings()
gateway_timeout = httpx.Timeout(10.0, connect=3.0)


def paise_to_rupees(paise: int) -> str:
    if paise <= 0:
        raise ValueError("Amount must be positive")
    value = Decimal(paise) / Decimal(100)
    return format(value.quantize(Decimal("0.01")), "f")


def rupees_to_paise(value: str | Decimal) -> int:
    decimal_value = Decimal(value).quantize(Decimal("0.01"))
    paise = decimal_value * Decimal(100)
    if paise != paise.to_integral_value():
        raise ValueError("Amount has more than two decimal places")
    return int(paise)

Step 2 · gateway client

Use bounded async I/O and validate the response mapping

The API key is sent only from FastAPI. A timeout bounds resource use, while response checks prevent a successful HTTP status from being mistaken for a correctly mapped payment order.

from typing import Any


async def create_gateway_order(
    *,
    client_txn_id: str,
    amount_paise: int,
    description: str,
    customer: dict[str, str | None],
) -> dict[str, Any]:
    request_body = {
        "client_txn_id": client_txn_id,
        "amount": paise_to_rupees(amount_paise),
        "p_info": description,
        "customer_name": customer.get("name"),
        "customer_email": customer.get("email"),
        "customer_mobile": customer.get("mobile"),
        "redirect_url": (
            f"{str(settings.public_store_url).rstrip('/')}"
            f"/orders/{client_txn_id}"
        ),
        "callback_url": (
            f"{str(settings.public_store_url).rstrip('/')}"
            "/webhooks/vyapargateway"
        ),
    }

    async with httpx.AsyncClient(timeout=gateway_timeout) as client:
        response = await client.post(
            (
                f"{str(settings.vg_base_url).rstrip('/')}"
                "/api/v1/create_order"
            ),
            headers={
                "Content-Type": "application/json",
                "X-API-Key": settings.vg_api_key.get_secret_value(),
            },
            json=request_body,
        )

    try:
        payload = response.json()
    except ValueError as exc:
        raise RuntimeError("Gateway returned invalid JSON") from exc

    data = payload.get("data")
    if response.is_error or payload.get("status") is not True or not data:
        reason = payload.get("detail") or payload.get("msg")
        raise RuntimeError(str(reason or "Gateway rejected the order"))

    if (
        data.get("client_txn_id") != client_txn_id
        or data.get("currency") != "INR"
    ):
        raise RuntimeError("Gateway response does not match local order")

    return data

Step 3 · checkout route

Price and persist before creating a payment

The repository, customer, cart, and catalogue services are application boundaries: they must enforce tenant ownership, database uniqueness, and trusted pricing. The payment logic shown here does not accept a browser-supplied total.

from fastapi import Depends, FastAPI, HTTPException, Request, status
from pydantic import BaseModel, Field

app = FastAPI()


class CheckoutRequest(BaseModel):
    cart_id: str = Field(min_length=1, max_length=64)


@app.post("/api/checkout", status_code=status.HTTP_201_CREATED)
async def checkout(
    payload: CheckoutRequest,
    request: Request,
    customer: Customer = Depends(require_customer),
):
    cart = await carts.load_owned(payload.cart_id, customer.id)
    priced_cart = await pricing.price_from_catalogue(cart)

    # The repository enforces UNIQUE(customer_id, cart_id) and
    # UNIQUE(client_txn_id); a retry loads the same pending order.
    order = await orders.insert_or_load_pending(
        cart_id=cart.id,
        customer_id=customer.id,
        amount_paise=priced_cart.total_paise,
    )

    try:
        gateway = await create_gateway_order(
            client_txn_id=order.client_txn_id,
            amount_paise=order.amount_paise,
            description=f"Order {order.public_number}",
            customer={
                "name": customer.name,
                "email": customer.email,
                "mobile": customer.mobile,
            },
        )
        await orders.attach_gateway_order(
            order.id,
            gateway_order_id=gateway["order_id"],
            payment_url=gateway["payment_url"],
            expires_at=gateway["expires_at"],
        )
    except (httpx.HTTPError, RuntimeError) as exc:
        request.app.state.logger.exception(
            "checkout failed",
            extra={"order_id": str(order.id)},
        )
        raise HTTPException(
            status_code=status.HTTP_502_BAD_GATEWAY,
            detail="Payment checkout is temporarily unavailable",
        ) from exc

    return {
        "order_id": order.public_number,
        "payment_url": gateway["payment_url"],
        "expires_at": gateway["expires_at"],
    }
If the HTTP request times out, its outcome is unknown. Reuse the stored client_txn_id or query status; never create a replacement ID automatically.

Step 4 · signed webhook

Verify the exact body, then transact

The signed message is timestamp + "." + exact raw JSON body. Constant-time comparison, freshness validation, schema validation, row locking, and a unique event insert each address a different failure mode.

import hashlib
import hmac
import json
import time
from typing import Literal

from fastapi import Header, Response
from pydantic import BaseModel, ValidationError


class GatewayEvent(BaseModel):
    event: str
    order_id: str
    client_txn_id: str
    status: Literal["success", "failed", "expired"]
    amount: Decimal
    currency: str
    upi_txn_id: str = ""
    timestamp: int


@app.post("/webhooks/vyapargateway", status_code=204)
async def vyapargateway_webhook(
    request: Request,
    x_vyapargateway_signature: str = Header(default=""),
    x_vyapargateway_timestamp: str = Header(default=""),
):
    raw_body = await request.body()
    if len(raw_body) > 256_000:
        raise HTTPException(status_code=413, detail="Webhook too large")

    try:
        timestamp = int(x_vyapargateway_timestamp)
    except ValueError as exc:
        raise HTTPException(
            status_code=401,
            detail="Invalid webhook timestamp",
        ) from exc

    if (
        len(x_vyapargateway_timestamp) != 10
        or abs(int(time.time()) - timestamp) > 300
    ):
        raise HTTPException(status_code=401, detail="Stale webhook")

    signed_message = (
        x_vyapargateway_timestamp.encode("ascii") + b"." + raw_body
    )
    expected = hmac.new(
        settings.vg_webhook_secret.get_secret_value().encode("utf-8"),
        signed_message,
        hashlib.sha256,
    ).hexdigest()
    if not hmac.compare_digest(
        expected,
        x_vyapargateway_signature.lower(),
    ):
        raise HTTPException(
            status_code=401,
            detail="Invalid webhook signature",
        )

    try:
        event = GatewayEvent.model_validate(json.loads(raw_body))
    except (json.JSONDecodeError, ValidationError) as exc:
        raise HTTPException(status_code=400, detail="Invalid event") from exc

    async with database.transaction() as tx:
        order = await tx.orders.lock_by_client_txn_id(event.client_txn_id)
        if order is None:
            raise HTTPException(status_code=404, detail="Unknown order")

        event_key = ":".join(
            (
                event.event,
                event.order_id,
                event.upi_txn_id or event.status,
            )
        )
        inserted = await tx.payment_events.insert_once(
            event_key=event_key,
            raw_body=raw_body,
        )
        if not inserted:
            return Response(status_code=204)

        if (
            event.order_id != order.gateway_order_id
            or event.currency != "INR"
            or rupees_to_paise(event.amount) != order.amount_paise
        ):
            raise HTTPException(
                status_code=409,
                detail="Payment does not match order",
            )

        if event.status == "success":
            await tx.orders.mark_paid_if_pending(
                order.id,
                upi_txn_id=event.upi_txn_id,
            )
            await tx.outbox.enqueue_once(
                key=f"fulfil-order:{order.id}",
                payload={"order_id": str(order.id)},
            )
        else:
            await tx.orders.record_gateway_status(order.id, event.status)

    return Response(status_code=204)

Step 5 · reconciliation

Recover uncertain states from a controlled worker

Reconcile ambiguous create calls, webhook outages, and overdue pending orders with bounded retries and backoff. Apply the same immutable-field checks and state machine as the webhook consumer.

async def check_gateway_order(
    *,
    order_id: str | None = None,
    client_txn_id: str | None = None,
) -> dict[str, Any]:
    if not order_id and not client_txn_id:
        raise ValueError("An order identifier is required")

    async with httpx.AsyncClient(timeout=gateway_timeout) as client:
        response = await client.post(
            (
                f"{str(settings.vg_base_url).rstrip('/')}"
                "/api/v1/check_order_status"
            ),
            headers={
                "Content-Type": "application/json",
                "X-API-Key": settings.vg_api_key.get_secret_value(),
            },
            json={
                "order_id": order_id,
                "client_txn_id": client_txn_id,
            },
        )

    payload = response.json()
    data = payload.get("data")
    if response.is_error or payload.get("status") is not True or not data:
        raise RuntimeError(
            str(payload.get("msg") or "Unable to reconcile payment")
        )
    return data

Test matrix

Prove the integration under concurrency

Money cases

Reject zero, negative, over-precision, overflow, and a gateway amount differing by one paise.

Signature cases

Reject missing, malformed, stale, future, wrong-secret, and body-mutated signatures.

Delivery cases

Accept exact duplicate delivery without a second order or fulfilment transition.

Race cases

Process concurrent webhook and reconciliation success with one paid transition.

Mapping cases

Reject cross-tenant IDs, wrong gateway order ID, wrong currency, and unknown client ID.

Recovery cases

Exercise timeout after remote success, delayed webhook, expired request, and status API failure.

Questions

FastAPI integration FAQ

Should a FastAPI application use float for rupee amounts?
No. Store integer paise and use Decimal only at the API boundary. A binary float can introduce rounding differences that are unacceptable when matching a payment to an order.
Can I use request.json() before webhook verification?
Read await request.body() first and verify the signature over those exact bytes. Parsing and re-encoding the body before verification may change its byte representation.
Should webhook fulfilment run in FastAPI BackgroundTasks?
Not for work that must survive a process restart. Commit the payment and an outbox record in one database transaction, then use a durable worker to perform fulfilment.
How do I prevent duplicate payment processing?
Use database uniqueness for the client transaction ID and verified event key, lock the mapped order during transition, and make paid-to-paid delivery a no-op.
What should the customer-facing status endpoint return?
Return your own order state after authentication and ownership checks. Do not expose the API key, webhook secret, raw provider payload, or an unrestricted gateway status proxy.

Treat retries as part of the protocol.

Stable IDs and durable transactions turn duplicate delivery into a safe no-op.

View all integrations