When I review payment integrations, I usually find the same three or four mistakes. The amount comes from the browser. The order is marked paid because the frontend said so. The webhook handler does everything inline and times out. And there’s no code at all for the case where the webhook comes before the browser callback.
None of these show up in testing. They show up in production, slowly, as a few strange tickets a week, and then suddenly, as a big problem during a sale.
This lesson walks through an integration that avoids all of them. I’ll use Python with FastAPI, and a generic gateway client, because the flow is the same for Razorpay, Cashfree, PayU, Juspay and most others. Only the names of the API calls and fields change. Where I show a specific format (like how the signature is computed), I’ll mention which gateway it’s from; check your own gateway’s docs for theirs.
The Safe Flow
The key ideas:
- The amount is decided on the server, from your database. The browser only says which cart or order to pay for.
- A payment row is saved before checkout opens. So even if everything after this fails, you have a record to reconcile.
- The result comes from two independent paths: the browser callback (fast, but untrusted until verified) and the webhook (reliable, server-to-server). Both go to the same function.
- Every message from the gateway is verified with a signature, and the important details are double-checked by fetching the payment from the gateway.
Let’s build each step.
Step 1: Tables
We need two tables to start: orders (your business object) and payments (attempts to pay for an order). One order can have many payment attempts. Lesson 6 expands this into a full state machine; this is enough for now.
CREATE TABLE orders (
id TEXT PRIMARY KEY, -- 'ord_812'
customer_id BIGINT NOT NULL,
amount_paise BIGINT NOT NULL CHECK (amount_paise > 0),
currency CHAR(3) NOT NULL DEFAULT 'INR',
status TEXT NOT NULL, -- 'pending_payment', 'paid', 'cancelled', ...
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE payments (
id BIGSERIAL PRIMARY KEY,
order_id TEXT NOT NULL REFERENCES orders(id),
gateway TEXT NOT NULL, -- 'razorpay'
gateway_order_id TEXT NOT NULL,
gateway_payment_id TEXT, -- filled once the customer pays
amount_paise BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
status TEXT NOT NULL, -- 'created', 'authorized', 'captured', 'failed', ...
method TEXT, -- 'upi', 'card', 'netbanking', ...
fee_paise BIGINT,
tax_paise BIGINT,
raw JSONB, -- last payload from gateway, for debugging
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (gateway, gateway_payment_id)
);
CREATE INDEX ON payments (gateway, gateway_order_id);Notice: amounts are BIGINT in paise. Not DECIMAL, not FLOAT. Integers are exact, fast, and simple. (DECIMAL is also exact and some teams prefer it; just never use floats.)
Step 2: Create the Order on the Server
import hashlib
import hmac
import json
import os
from fastapi import Depends, FastAPI, HTTPException, Request, Response
app = FastAPI()
GATEWAY_KEY_ID = os.environ["PG_KEY_ID"] # public, safe to send to the browser
GATEWAY_SECRET = os.environ["PG_KEY_SECRET"] # never leaves the server
WEBHOOK_SECRET = os.environ["PG_WEBHOOK_SECRET"] # separate secret for webhooks
@app.post("/checkout")
def checkout(body: dict, user=Depends(current_user)):
order = db.orders.get(body["order_id"])
if order is None or order.customer_id != user.id:
raise HTTPException(404, "Order not found")
if order.status != "pending_payment":
raise HTTPException(409, "Order is not awaiting payment")
# Amount from OUR database, never from the request body
pg_order = pg.create_order(
amount=order.amount_paise,
currency=order.currency,
receipt=order.id, # helps match records later
notes={"order_id": order.id, "customer_id": str(user.id)},
)
db.payments.insert(
order_id=order.id, gateway="razorpay", gateway_order_id=pg_order["id"],
amount_paise=order.amount_paise, currency=order.currency, status="created",
)
return {
"key_id": GATEWAY_KEY_ID,
"gateway_order_id": pg_order["id"],
"amount": order.amount_paise,
"currency": order.currency,
"prefill": {"name": user.name, "email": user.email, "contact": user.phone},
}A few things worth noticing:
- We check that the order belongs to the logged-in user. Otherwise one user could start payment on another user’s order.
- We check the order status. Paying for an order that’s already paid or cancelled should be impossible.
receiptandnotescarry your own order id to the gateway. When you look at the gateway dashboard or settlement report later, you can match their records to yours. Always pass your id.- The payment row is saved before returning. If the customer pays and then everything crashes, you still have a row with
gateway_order_idto look up.
Should you create a new gateway order for every checkout attempt, or reuse one? Both work. Reusing one gateway order per order (until it expires) keeps things simple, and multiple payment attempts naturally attach to it. If the cart amount can change between attempts, create a new gateway order whenever the amount changes.
Step 3: Open Checkout
On the frontend, you open the gateway’s checkout with the details from step 2. Every gateway has a JavaScript, Android, iOS, React Native and Flutter SDK for this. The customer picks UPI, card, net banking, etc., and pays inside the gateway’s secure UI.
When it finishes, the SDK calls your success handler with something like:
{
"gateway_order_id": "order_N8xY2...",
"gateway_payment_id": "pay_N8xZ9...",
"signature": "9f86d081884c7d659a2feaa0c55ad015..."
}The frontend sends this to your backend. And here’s the rule from Lesson 2 again: this message proves nothing until you verify it.
Step 4: Verify the Signature
The gateway signs the result using your secret key. Since only you and the gateway know the secret, nobody else can create a valid signature.
For example, Razorpay’s checkout signature is an HMAC-SHA256 of order_id + "|" + payment_id, using your key secret. Other gateways use different fields or formats, but the idea is the same.
def verify_checkout_signature(gateway_order_id: str, gateway_payment_id: str,
signature: str) -> bool:
message = f"{gateway_order_id}|{gateway_payment_id}".encode()
expected = hmac.new(GATEWAY_SECRET.encode(), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)Use hmac.compare_digest, not ==. A normal string comparison stops at the first different character, and in theory the time difference can leak information about the expected signature. compare_digest takes the same time either way.
Step 5: Fetch and Check the Payment
A valid signature tells you the gateway really sent this message. It doesn’t tell you the payment amount matches your order, or that the payment is captured (it could be only authorised). So after verifying, fetch the payment from the gateway and check the details:
@app.post("/payments/verify")
def verify_payment(body: dict, user=Depends(current_user)):
if not verify_checkout_signature(body["gateway_order_id"], body["gateway_payment_id"],
body["signature"]):
raise HTTPException(400, "Invalid signature")
payment_row = db.payments.find_one(gateway="razorpay",
gateway_order_id=body["gateway_order_id"])
if payment_row is None:
raise HTTPException(404, "Unknown order")
p = pg.fetch_payment(body["gateway_payment_id"]) # server-to-server call
result = apply_gateway_payment(payment_row.order_id, p)
return {"order_status": result.order_status}And the shared function that both the browser path and the webhook path will call:
def apply_gateway_payment(order_id: str, p: dict):
order = db.orders.get(order_id)
# 1. Does this payment belong to this order?
if p["order_id"] not in db.payments.gateway_order_ids_for(order_id):
alert("payment/order mismatch", order_id=order_id, payment=p["id"])
raise ValueError("payment does not belong to order")
# 2. Amount and currency must match exactly
if p["amount"] != order.amount_paise or p["currency"] != order.currency:
alert("amount mismatch", order_id=order_id, payment=p["id"])
return mark_for_manual_review(order, p)
# 3. Record this attempt (insert or update by gateway_payment_id)
db.payments.upsert(
gateway="razorpay", gateway_payment_id=p["id"], order_id=order_id,
gateway_order_id=p["order_id"], amount_paise=p["amount"], currency=p["currency"],
status=p["status"], method=p.get("method"), fee_paise=p.get("fee"),
tax_paise=p.get("tax"), raw=p,
)
# 4. Move the order forward only on capture, and only once (Lesson 5)
if p["status"] == "captured":
return mark_order_paid_once(order, payment_id=p["id"])
return OrderResult(order_status=order.status)The checks in this function have each caught real problems for teams I’ve worked with:
- Order mismatch: a frontend bug sent the payment id from a previous order.
- Amount mismatch: a discount coupon was applied in the cart after the gateway order was created, so the customer paid the old amount. Without this check, the order would have been marked paid for the wrong amount.
- Status check: a gateway account accidentally switched from auto-capture to manual capture after a settings change, so payments were only “authorised”. The orders were being marked paid, and the money was never actually collected.
Step 6: The Webhook
The webhook is the most reliable signal you have. The customer can close the browser, but the gateway will still call your webhook URL, and it will retry if your server doesn’t respond.
Things to get right:
- Verify the signature on the raw body. The gateway signs the exact bytes it sent. If you parse JSON and re-serialise it, the bytes change (spaces, key order) and the signature won’t match. Read the raw body first.
- Use a separate webhook secret, configured in your gateway dashboard. Don’t reuse the API key secret.
- Respond fast. Gateways expect a 2xx response within a few seconds. If your handler calls three internal services and a DB, it will sometimes time out, the gateway will retry, and now you have duplicates and load spikes. Store the event, return 200, process it from a queue.
- Return 200 for events you don’t care about. Otherwise the gateway keeps retrying them.
@app.post("/webhooks/pg")
async def pg_webhook(request: Request):
raw = await request.body() # exact bytes
signature = request.headers.get("X-Razorpay-Signature", "") # header name varies by gateway
expected = hmac.new(WEBHOOK_SECRET.encode(), raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
return Response(status_code=400)
event = json.loads(raw)
event_id = request.headers.get("X-Razorpay-Event-Id") or event.get("id")
# Store first. ON CONFLICT DO NOTHING makes duplicates harmless (Lesson 5).
inserted = db.webhook_events.insert_if_new(
gateway="razorpay", event_id=event_id, event_type=event["event"], payload=event,
)
if inserted:
queue.enqueue("process_pg_event", event_id=event_id)
return {"ok": True} # fast 200And the worker that processes it:
def process_pg_event(event_id: str):
ev = db.webhook_events.get(event_id)
if ev.event_type in ("payment.captured", "payment.authorized", "payment.failed"):
p = ev.payload["payload"]["payment"]["entity"]
order_id = p["notes"].get("order_id") or db.payments.order_for_gateway_order(p["order_id"])
# Optional but safer: re-fetch from gateway instead of trusting payload fields
p = pg.fetch_payment(p["id"])
apply_gateway_payment(order_id, p)
elif ev.event_type.startswith("refund."):
handle_refund_event(ev) # Lesson 8
db.webhook_events.mark_processed(event_id)Notice the worker calls the same apply_gateway_payment as the browser path. So whichever path arrives first marks the order paid, and the second one finds it already paid and does nothing. Lesson 5 shows exactly how to make mark_order_paid_once safe even when both arrive at the same millisecond.
Keys, Environments and Secrets
- Every gateway gives you test and live keys. Keep them in separate environment configs. A surprisingly common incident: production deployed with test keys, so every payment “succeeds” in test mode and no money is collected. Add a startup check that fails if the environment is production and the key looks like a test key.
- Key secret and webhook secret stay on the server. Never in the mobile app, never in frontend code, never in Git. Use your cloud’s secret manager.
- Rotate secrets if a laptop is lost or someone leaves with access. Know how to do it before you need to.
- Restrict dashboard access. The gateway dashboard can issue refunds and change settings. Use role-based access and 2FA for everyone.
Testing Your Integration
Gateways provide test cards, test UPI IDs and test bank flows that succeed or fail on purpose. Use them to test every path:
- Success via browser callback first, then webhook.
- Webhook first, then browser callback (yes, this happens).
- Browser callback never arrives (close the tab after paying).
- Payment fails, customer retries, second attempt succeeds.
- Two successful payments for one order.
- Webhook delivered twice.
- Amount tampering (change the amount in the browser; your backend must ignore it).
- Invalid signature.
For webhooks in local development, use a tunnelling tool so the gateway can reach your laptop, or replay saved webhook payloads with the correct signature computed by a small test helper.
Pitfalls to Avoid
- Amount from the client. Always compute the amount on the server from your database.
- Marking paid on the browser callback alone. Verify the signature, then fetch the payment and check status, amount, currency and order.
- No payment row before checkout. Save it first, so a crash later doesn’t lose the trail.
- Not passing your order id to the gateway. Use
receipt/notesso you can match records in reports and reconciliation. - Verifying the webhook signature on parsed JSON. Use the raw body bytes.
- Slow webhook handlers. Store the event, return 200, process from a queue.
- Returning errors for events you don’t handle. The gateway will keep retrying them.
- Different logic for callback and webhook. One shared function, called from both.
- Secrets in the app or in Git. Server-side only, in a secret manager.
- Test keys in production. Add a startup check.
Quick Recap
- Server creates the gateway order with the amount from your database, and saves a payment row first.
- Checkout happens in the gateway’s secure UI.
- Verify every signature with HMAC and constant-time compare, then fetch the payment and check status, amount, currency and order.
- Webhooks: verify on raw body, store, return 200 fast, process from a queue.
- Browser callback and webhook both call one idempotent function.
That “idempotent” word keeps coming up. Next lesson is all about it: how to make sure that retries, duplicate webhooks and double clicks never charge a customer twice or mark an order paid twice.