Trusted input
Authenticate the customer and calculate the cart from server catalogue data.
Python · FastAPI · httpx
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
Authenticate the customer and calculate the cart from server catalogue data.
Persist one client_txn_id before calling the gateway and reuse it after uncertainty.
Authenticate exact bytes, then validate the parsed event against the locked order.
Commit payment plus outbox atomically; let a worker perform fulfilment after commit.
Step 1 · configuration and money
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
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
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"],
} client_txn_id or query status; never create a
replacement ID automatically.
Step 4 · signed webhook
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
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
Reject zero, negative, over-precision, overflow, and a gateway amount differing by one paise.
Reject missing, malformed, stale, future, wrong-secret, and body-mutated signatures.
Accept exact duplicate delivery without a second order or fulfilment transition.
Process concurrent webhook and reconciliation success with one paid transition.
Reject cross-tenant IDs, wrong gateway order ID, wrong currency, and unknown client ID.
Exercise timeout after remote success, delayed webhook, expired request, and status API failure.
Questions
Stable IDs and durable transactions turn duplicate delivery into a safe no-op.