Case study
A Chosen Son — Payment Webhook
A FastAPI service that turns Razorpay's payment.captured events into the studio's back-office actions — routing 44 products to 8 handlers, writing rows to Google Sheets, minting Calendly links, sending branded email and paging Telegram on every branch.
- Role
- Senior developer — design & build
- Mode
- Solo
- Headline result
- 44 products 8 handlers · one webhook
Context
A Chosen Son sells forty-plus different consultation and report products through Razorpay Payment Pages. Each product needs a different downstream action: a row in a specific Google Sheet, a Calendly link mailed to the customer, or both — and the studio operations team needs to know within seconds when a payment lands or a booking fails. I built this service in February 2026 as the senior developer on the project; it's been the studio's back-office glue ever since, live at payment.achosenson.com.
The problem
Before this service existed, every new product meant a hand-wired Zapier or manual copy-paste to move a payment into the right sheet, and Calendly links were sent by hand from the founder's inbox. That worked at ten products; at forty-four it was a bug factory — mis-typed dates, duplicate rows on Razorpay retries, silent drops when a note field was renamed. The service had to accept Razorpay webhooks at the edge, dispatch the right handler for the right product, write structured rows to the right sheet, mint a one-time Calendly link for the six service types that need one, mail the customer a booking prompt, and page the operator on every branch — success, duplicate, unknown product or hard failure — all without losing a single payment.
Architecture & solution
A single FastAPI app on Python 3.12 exposes POST /webhooks/razorpay and GET /health, packaged as a Docker container and run under Uvicorn with four workers. The webhook is guarded by a file-based idempotency store (processing_order.json + processed_orders.json) that blocks concurrent processing of the same Razorpay order_id and short-circuits duplicates. A registry maps product name → handler class — QNA, TEAM, AFFORDABLE, SAARTHI, NUMEROLOGY, STONE, WRITTEN and WHATSAPP — and each handler owns its own field-mapping onto one or more Google Sheets tabs through gspread. The sheet helper reads headers from the sheet as the source of truth, opts into string_escaping="full" so Sheets doesn't reinterpret DD/MM/YYYY birth dates as US-format or 24-hour times as time serials, and can auto-append new columns for services that add them without hand-editing the tab. A CalendlyService mints a max_event_count=1 scheduling link on demand, and an EmailService sends a branded HTML mail carrying that link and the Razorpay Order ID. A dead-letter queue captures the full payload of any event that can't be processed, and a small Telegram utility posts a structured log line on every branch so the operator sees every payment in real time.
Key decisions & tradeoffs
FastAPI plus a plain Python container was the right size for a webhook that runs a few thousand times a day — no queue, no broker, no cache tier to operate, and the handler dispatch is straightforward enough that a class-per-service map is more legible than a framework. Idempotency lives in flat JSON files on disk rather than Redis because the deploy is a single container behind a reverse proxy: one process, one file, no coordination problem to solve, and the same files are trivially inspectable when something goes sideways. Every failure returns 200 to Razorpay after pushing the payload to a DLQ — the alternative (5xx and retry) was actively harmful once we had a poison event, and the DLQ makes replay a scripted operation rather than a live-fire debug. Telegram was chosen over email or a dashboard because the studio team already lives there and a chat message is the shortest path from event to human; the service posts a status on success, duplicate, unknown product and failure so silence is meaningful. The sheet writer defaults to string_escaping="default" but flips to "full" for handlers whose payload contains birth dates and phone numbers — a specific fix for a specific class of Google Sheets bug that was silently corrupting customer data.
Results
Live at payment.achosenson.com since February 2026, processing Razorpay webhooks for the studio's full product line — 44 services routed through 8 handlers, 6 of them Calendly-enabled with mailed booking prompts. The Telegram feed gives the operations team a real-time ledger of every payment, and the DLQ has replayed cleanly on every unknown-product incident so far without dropping a paying customer. New products land as a registry entry rather than a code change, and new handlers land as a class alongside the existing eight without touching the router.
Learnings
The one that stuck was that returning 200 on a handled failure is the correct default for a payments webhook — retries protect you from network faults, but they weaponize your bugs, and a DLQ + Telegram alert beats a retry loop hammering a broken handler at three in the morning. The one I'd change is the file-based idempotency store: it's fine for one process on one box, but the moment we scale to a second container the store becomes a race, and swapping it for Redis is a bigger surgery when done under load than it would be to do up front. The satisfying part is how small the service stayed — one FastAPI file, a handler per product family, a sheet helper that knows about Sheets' quirks, and a Telegram line at every branch — and how much of the studio's operational surface it moved off human hands.
// contact
Building something like this?
If this maps to what you're planning, I can walk you through how I'd approach yours.