You deploy a payments webhook, a test payment goes through, and your logs say signature mismatch. Or worse, nothing fails in testing and then a customer gets 500 credits twice because Razorpay retried an event your server had already processed.
Here's the short version. Verify the HMAC-SHA256 signature over the raw request bytes, record the event id in the same database transaction as your business change, and make every state change safe to repeat. The rest of this post is a FastAPI handler that does exactly that, plus a local mock sender that signs payloads for real and attacks the handler in ten ways. The output is pasted from a run on 2026-10-09.
Razorpay's behaviour below comes from its docs, which I downloaded and read on 2026-10-09; the wording is paraphrased, so check it on the pages linked. I did not call Razorpay's live API or receive a real webhook; every request in this post came from my own mock.
What does Razorpay promise about webhook delivery?
Before writing code, pin down the contract. From the webhook validation page and the best-practices page (read 2026-10-09):
| Topic | What the docs say |
|---|---|
| Signature | In the X-Razorpay-Signature header. HMAC with SHA-256, your webhook secret as key, the request body as message |
| Body | Use the raw body. The page warns not to parse or cast it before verifying |
| Duplicates | The same event may arrive more than once. The x-razorpay-event-id header is unique per event and is the way to detect duplicates |
| Ordering | Not guaranteed. Example given: payment.captured can arrive before payment.authorized |
| Success | Any 2xx. Anything else is a failed delivery |
| Timeout | 5 seconds, then the delivery is marked timed out |
| Retries | Exponential backoff for 24 hours after the event was created. Exact intervals are not given on the page I read |
| Disabling | If a webhook keeps failing for 24 hours it is disabled, and you re-enable it from the Dashboard |
| Ports | Webhook URLs must use port 80 or 443, per the overview page |
Two more points matter in practice. The overview page says to allowlist Razorpay's webhook IPs if your cloud firewall blocks unknown sources, and recommends calling the fetch APIs (Payments, Orders, Refunds) when a time-sensitive flow hasn't received its webhook. And if you rotate the webhook secret, the validation page says to verify retried older requests with the old secret.
That table already shapes the design. Duplicates and disorder mean the handler must be idempotent and monotonic. The 5-second timeout means you shouldn't call an LLM or send an email inline before answering.
How do you verify the signature in FastAPI?
The Python SDK has a helper for this (the docs name verify_webhook_signature), and I haven't run it. The check is small enough to do with the standard library, which also shows what's happening:
import hashlib
import hmac
def verify(raw: bytes, signature: str, secret: bytes) -> bool:
expected = hmac.new(secret, raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
Use hmac.compare_digest, not ==, so the comparison time doesn't leak how many leading characters matched. Get raw from await request.body(). If you declare a Pydantic model as the endpoint parameter, FastAPI parses the JSON before you can see the bytes, and you've lost the thing you need to sign.
The secret is the one you typed into the webhook form in the Razorpay Dashboard, not your API key secret. Keep it in an environment variable.
The full handler, with the event id and the business change in one transaction
This is the file I tested. It uses SQLite so you can run it anywhere; the same shape works in Postgres with a unique constraint and INSERT ... ON CONFLICT DO NOTHING.
import hashlib
import hmac
import json
import os
import sqlite3
from fastapi import FastAPI, Header, HTTPException, Request
WEBHOOK_SECRET = os.environ.get("RZP_WEBHOOK_SECRET", "").encode()
DB_PATH = os.environ.get("DB_PATH", "/tmp/Y3a/app.db")
FAIL_ONCE_FILE = os.environ.get("FAIL_ONCE_FILE", "") # test hook: simulate a crash
# Payment status only moves forward, so a late "authorized" cannot undo "captured".
RANK = {"created": 0, "authorized": 1, "captured": 2, "failed": 1}
app = FastAPI()
def db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH, timeout=10, isolation_level=None)
conn.execute("PRAGMA journal_mode=WAL")
return conn
def init_db() -> None:
conn = db()
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS webhook_events (
event_id TEXT PRIMARY KEY,
event TEXT NOT NULL,
received_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS payments (
payment_id TEXT PRIMARY KEY,
order_id TEXT,
amount INTEGER,
status TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS credit_ledger (
payment_id TEXT PRIMARY KEY, -- one grant per payment, whatever events arrive
user_ref TEXT,
credits INTEGER NOT NULL
);
"""
)
conn.close()
init_db()
def verify(raw: bytes, signature: str) -> bool:
expected = hmac.new(WEBHOOK_SECRET, raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
def apply_event(conn: sqlite3.Connection, event: str, body: dict) -> None:
pay = body.get("payload", {}).get("payment", {}).get("entity")
if not pay:
return # event types we do not act on (still recorded as seen)
status = pay["status"]
row = conn.execute(
"SELECT status FROM payments WHERE payment_id = ?", (pay["id"],)
).fetchone()
if row is None:
conn.execute(
"INSERT INTO payments (payment_id, order_id, amount, status) VALUES (?,?,?,?)",
(pay["id"], pay.get("order_id"), pay["amount"], status),
)
elif RANK.get(status, 0) > RANK.get(row[0], 0):
conn.execute(
"UPDATE payments SET status = ? WHERE payment_id = ?", (status, pay["id"])
)
if event == "payment.captured":
# Razorpay's sample payloads show "notes": [] (a list) when empty, so don't assume a dict
notes = pay.get("notes")
user_ref = notes.get("user_ref") if isinstance(notes, dict) else None
# 1 credit per rupee; the amount arrives in paise
conn.execute(
"INSERT OR IGNORE INTO credit_ledger (payment_id, user_ref, credits) VALUES (?,?,?)",
(pay["id"], user_ref, pay["amount"] // 100),
)
if FAIL_ONCE_FILE and os.path.exists(FAIL_ONCE_FILE):
os.remove(FAIL_ONCE_FILE)
raise RuntimeError("simulated crash mid-processing")
@app.post("/webhooks/razorpay")
async def razorpay_webhook(
request: Request,
x_razorpay_signature: str = Header(default=""),
x_razorpay_event_id: str = Header(default=""),
):
raw = await request.body() # exact bytes; never re-serialise before verifying
if not x_razorpay_signature or not verify(raw, x_razorpay_signature):
raise HTTPException(status_code=400, detail="bad signature")
if not x_razorpay_event_id:
raise HTTPException(status_code=400, detail="missing event id")
body = json.loads(raw)
event = body.get("event", "")
conn = db()
try:
conn.execute("BEGIN IMMEDIATE")
cur = conn.execute(
"INSERT OR IGNORE INTO webhook_events (event_id, event) VALUES (?, ?)",
(x_razorpay_event_id, event),
)
if cur.rowcount == 0:
conn.execute("ROLLBACK")
return {"status": "duplicate"}
apply_event(conn, event, body)
conn.execute("COMMIT")
return {"status": "processed"}
except Exception:
conn.execute("ROLLBACK") # event id not recorded, so a retry will run again
raise HTTPException(status_code=500, detail="processing failed")
finally:
conn.close()
Four design choices are worth defending, because each one fixes a specific failure.
The event id and the business change commit together. If you record the id first and crash before applying the change, the retry is treated as a duplicate and the customer never gets their credits. If you apply the change first and crash before recording the id, the retry applies it twice. One transaction removes both cases. A failure rolls back, returns 500, and Razorpay's retry gets a clean second attempt.
There are two layers of idempotency. The event id catches the same delivery arriving twice. The credit_ledger primary key on payment_id catches the case where two different events (different ids) describe one payment, for example if you subscribe to both payment.captured and order.paid. Don't rely on the first layer alone.
Status moves forward only. The RANK table makes a late payment.authorized unable to downgrade a captured payment. This is my own rule for the mock's statuses, not a Razorpay state machine, so adapt it to the events you subscribe to.
Unknown events return 200. If you subscribe to more events than you act on, answering 4xx makes Razorpay retry something you'll never process. Record it and move on.
Testing it against a signed local mock
Unit tests that skip the signature prove little. I wrote a mock sender that starts the app with uvicorn, builds the JSON body, signs the exact bytes with HMAC-SHA256 using a throwaway secret, and posts it over HTTP with the same two headers Razorpay documents. The payload is an abbreviated shape I wrote for payment.captured. I compared it afterwards with the sample payloads on Razorpay's Payments webhook events page (read 2026-10-09): the path payload.payment.entity with id, amount, status and order_id matches. One difference mattered: the sample shows "notes": [], a list, when there are no notes, while my mock sent a dict. The handler above reads notes defensively for that reason (my first version called .get on it and would have crashed on the sample shape; I re-ran all ten scenarios after the fix and the output is unchanged). The amount is treated as paise, 49900 meaning Rs. 499; confirm units for your account on the docs.
Here is the run, pasted as printed:
1 valid payment.captured (evt_1) -> 200 {"status":"processed"}
state (events, credits, statuses): (1, 499, {'pay_A1': 'captured'})
2 exact replay of evt_1 -> 200 {"status":"duplicate"}
state: (1, 499, {'pay_A1': 'captured'})
3 tampered amount, original signature -> 400 {"detail":"bad signature"}
4 signed with wrong secret -> 400 {"detail":"bad signature"}
5 no signature header -> 400 {"detail":"bad signature"}
6 re-serialised JSON, original signature -> 400 {"detail":"bad signature"}
bytes equal? False | len original 270 len reserialised 295
state: (1, 499, {'pay_A1': 'captured'})
7a captured first (evt_6) -> 200 {"status":"processed"}
7b authorized late (evt_7) -> 200 {"status":"processed"}
state: (3, 998, {'pay_A1': 'captured', 'pay_B2': 'captured'})
8 payment.captured for pay_B2 under new id evt_8 -> 200 {"status":"processed"}
state: (4, 998, {'pay_A1': 'captured', 'pay_B2': 'captured'})
9 20 parallel deliveries of evt_9 -> {'{"status":"duplicate"}': 19, '{"status":"processed"}': 1}
state: (5, 1998, {'pay_A1': 'captured', 'pay_B2': 'captured', 'pay_C3': 'captured'})
10a first attempt crashes mid-processing (evt_10) -> 500 {"detail":"processing failed"}
state after crash: (5, 1998, {'pay_A1': 'captured', 'pay_B2': 'captured', 'pay_C3': 'captured'})
10b retry of evt_10 -> 200 {"status":"processed"}
state after retry: (6, 2297, {'pay_A1': 'captured', 'pay_B2': 'captured', 'pay_C3': 'captured', 'pay_D4': 'captured'})
How to read it:
- Cases 1 and 2. First delivery credits 499. The replay answers 200 with
duplicateand the balance stays 499. Returning 200 on a duplicate matters: a 4xx would make Razorpay keep retrying. - Cases 3 to 5. A changed amount, a wrong secret and a missing header are all rejected with 400, and the state didn't move.
- Case 6. The data was identical but the bytes weren't: re-serialising added spaces and grew the body from 270 to 295 bytes, so the signature no longer matched. This is the classic "works with curl, fails in my framework" bug. It's the reason the handler reads
request.body()first. - Cases 7 and 8. The late
authorizedevent is recorded (3 events) but the payment stayscaptured. Case 8 sends a secondpayment.capturedfor the same payment under a new event id; the event count goes to 4 but credits stay at 998 because the ledger refuses a second grant for that payment id. - Case 9. Twenty parallel requests with one event id: one
processed, nineteenduplicate, credits added once.BEGIN IMMEDIATEserialises the writers, and the primary key is the final guard. - Case 10. I forced an exception after the writes but before the commit. The response was 500 and the table counts didn't change, so the retry succeeded cleanly with 299 credits.
What this does not prove: it uses my mock, not Razorpay's infrastructure. It doesn't show Razorpay's actual retry timing, the real payload shapes, or the production behaviour of SQLite under heavy load. Use Razorpay's test mode and the Dashboard's webhook tooling for the end-to-end check.
What should you do after returning 200?
Anything slow. The docs say a response slower than 5 seconds is marked timed out, so don't call an LLM, render a PDF or email a customer before you answer. The simplest pattern: the handler's transaction writes the event row plus an outbox row ("send receipt for pay_A1"), and a separate worker reads the outbox. That keeps the 5-second path to one database transaction. If the worker later fails, you retry your own outbox instead of waiting for Razorpay. For agent-style follow-up work triggered by payments, see webhook-driven AI agents.
If you're building the surrounding service, FastAPI and the Claude API in production covers the app structure, and the async Python patterns post explains why blocking calls inside async def handlers deserve care. My handler does a short blocking SQLite call inside an async function. That's fine for this demo's volume, but at higher load move the database work to a thread pool or use an async driver.
Security and operations checklist
- Reject before parsing. Verify the signature before you
json.loadsanything. - Keep the webhook secret out of the repo. Rotating it? Keep the old one around until retried events (up to 24 hours per the docs) have drained.
- Razorpay's best-practices page lists whitelisting its webhook IPs as part of the integration checklist. Do it if you can keep the list current, but the signature is your real authentication.
- Log the event id, event type and outcome (
processed,duplicate,bad signature). Don't log full payloads with customer details. - Alert on webhook disablement. Razorpay's page says a 24-hour failure streak disables the webhook and sends an alert email, which is a long time to lose payment events.
- Reconcile. Run a daily job that fetches yesterday's captured payments from the API and compares them with your ledger. Webhooks are the fast path, not the only path.
- Keep a replay test in CI: resend a stored event and tamper with a stored body. If either changes your balances, the design is wrong.
If you don't have a Razorpay account yet, you can sign up through this link. Some links on this page are affiliate links; they cost you nothing and I only link tools I'd mention anyway. Cashfree is a fair alternative, and our Cashfree vs Razorpay comparison covers the trade-offs. Cashfree has its own signing scheme and event names, so don't copy this handler across without reading its docs. If you take recurring payments, the UPI AutoPay subscriptions post shows how to map subscription events to entitlements, and the credits model post covers the ledger side.



