When a customer completes a checkout on Razorpay, relying solely on client-side frontend callbacks (handler in Razorpay Checkout.js) is a recipe for lost revenue. If the user closes their browser tab, loses mobile connectivity, or gets redirected improperly after entering their UPI PIN, your frontend never fires.
The only source of truth for payment status is the Razorpay Webhook.
However, handling payment webhooks comes with strict production requirements:
1. Missed events mean angry customers: If your webhook endpoint is down or crashes, the customer has paid money, but their subscription is never activated or order is never fulfilled.
2. Signature verification is non-negotiable: Anyone can forge an HTTP POST to your webhook URL claiming payment.captured. You must verify Razorpay's HMAC SHA256 signature.
3. Razorpay retries for 24 hours: If your server takes more than 5 seconds to reply or returns a non-200 code, Razorpay retries the same event multiple times with exponential backoff. Your handler must be idempotent to prevent double-crediting users.
Here is how to build and deploy an enterprise-grade, serverless Razorpay webhook handler in Python.
Razorpay Webhook Lifecycle
Customer Checkout (UPI / Card / Netbanking)
│
▼
Razorpay Payment Engine
│
[payment.captured event]
│
POST /w/razorpay-handler (HMAC Signed)
│
LiteLambda Serverless Webhook
├─► 1. Verify X-Razorpay-Signature (HMAC-SHA256)
├─► 2. Check Idempotency Key (Skip if already processed)
├─► 3. Return 200 OK (< 100ms to avoid timeout)
└─► 4. Grant access in DB, send WhatsApp/Email receipt
Verifying Razorpay Webhook Signatures in Python
Razorpay sends an X-Razorpay-Signature HTTP header containing an HMAC-SHA256 hash computed using your Webhook Secret and the raw JSON request body.
Here is the exact signature verification function in Python:
import hmac
import hashlib
def verify_razorpay_signature(raw_body: str, signature: str, secret: str) -> bool:
"""
Verifies that the incoming webhook genuinely came from Razorpay.
Prevents forged requests and replay tampering.
"""
if not signature or not secret:
return False
expected_signature = hmac.new(
key=secret.encode("utf-8"),
msg=raw_body.encode("utf-8"),
digestmod=hashlib.sha256
).hexdigest()
# Use constant-time comparison to prevent timing attacks
return hmac.compare_digest(expected_signature, signature)
The Complete Production Handler: Verification, Idempotency & Provisioning
The following script verifies the signature, enforces idempotency using a Key-Value store, and provisions access:
import os
import json
import requests
from verify import verify_razorpay_signature
def handler(event, context):
"""
Handles Razorpay payment and subscription webhooks.
"""
# 1. Extract raw body and headers
raw_body = event.get("body", "")
headers = event.get("headers", {})
signature = headers.get("x-razorpay-signature") or headers.get("X-Razorpay-Signature")
webhook_secret = os.environ.get("RAZORPAY_WEBHOOK_SECRET")
if not webhook_secret:
return {"status": 500, "error": "Server missing webhook secret"}
# 2. Strict Signature Verification
if not verify_razorpay_signature(raw_body, signature, webhook_secret):
print(f"⚠️ Invalid signature attempt from IP: {event.get('ip')}")
return {"status": 401, "error": "Signature verification failed"}
# 3. Parse JSON payload
try:
data = json.loads(raw_body)
except json.JSONDecodeError:
return {"status": 400, "error": "Malformed JSON payload"}
event_type = data.get("event")
payload = data.get("payload", {})
# 4. Handle 'payment.captured' (One-time payments)
if event_type == "payment_intent.succeeded" or event_type == "payment.captured":
payment_entity = payload.get("payment", {}).get("entity", {})
payment_id = payment_entity.get("id")
order_id = payment_entity.get("order_id")
amount_paisa = payment_entity.get("amount") # Razorpay sends amount in paisa (100 paisa = 1 INR)
amount_inr = amount_paisa / 100 if amount_paisa else 0.0
email = payment_entity.get("email")
contact = payment_entity.get("contact")
notes = payment_entity.get("notes", {})
# ── Idempotency Check ────────────────────────────────────────────────
# Prevent double fulfillment if Razorpay retries the same event
idempotency_key = f"rzp_captured_{payment_id}"
# Check built-in context KV or Redis
if hasattr(context, "kv"):
if context.kv.get(idempotency_key):
print(f"ℹ️ Payment {payment_id} already processed. Skipping.")
return {"status": 200, "message": "Already processed"}
# Mark processed for 7 days
context.kv.set(idempotency_key, "1", ttl=604800)
print(f"✅ Processing payment {payment_id} for {amount_inr} INR from {email}")
# 5. Provision User Access in your Main Database
grant_customer_access(
email=email,
order_id=order_id,
payment_id=payment_id,
plan=notes.get("plan_id", "pro")
)
# 6. Send Instant Confirmation via WhatsApp / Email
send_whatsapp_receipt(
phone=contact,
amount=amount_inr,
payment_id=payment_id
)
# 7. Handle 'payment.failed'
elif event_type == "payment.failed":
payment_entity = payload.get("payment", {}).get("entity", {})
payment_id = payment_entity.get("id")
error_desc = payment_entity.get("error_description", "Unknown error")
print(f"❌ Payment failed: {payment_id} - Reason: {error_desc}")
# Log to analytics or trigger abandoned cart workflow
# Always return 200 OK so Razorpay acknowledges delivery
return {"status": 200, "received": True}
def grant_customer_access(email: str, order_id: str, payment_id: str, plan: str):
"""Call your main application API or direct DB to activate membership."""
api_url = os.environ.get("APP_INTERNAL_API_URL")
api_key = os.environ.get("APP_INTERNAL_API_KEY")
if api_url and api_key:
requests.post(
f"{api_url}/v1/users/upgrade",
headers={"Authorization": f"Bearer {api_key}"},
json={"email": email, "order_id": order_id, "payment_id": payment_id, "plan": plan},
timeout=8
)
def send_whatsapp_receipt(phone: str, amount: float, payment_id: str):
"""Send transaction confirmation via Interakt / WATI / Gupshup."""
# Custom WhatsApp BSP integration logic here
pass
Why You Shouldn't Run Payment Webhooks on a Shared VPS
Payment webhooks require 99.99% availability. If your VPS rebooted for security patches or your Nginx proxy encountered a memory leak when a flash sale started, you lose customer payments.
On LiteLambda, webhook endpoints are isolated in serverless micro-sandboxes. They scale to thousands of simultaneous checkouts without server bottlenecks, verify signatures automatically, and store execution history with full request logs.
Razorpay Webhook Configuration: Step-by-Step
1. Deploy the Handler to LiteLambda
pip install litelambda-cli
litelambda login
litelambda deploy razorpay-webhook handler.py \
--type webhook \
--env RAZORPAY_WEBHOOK_SECRET="whsec_9vK3pLm8Nx2Q..." \
--env APP_INTERNAL_API_URL="https://api.yourdomain.com" \
--env APP_INTERNAL_API_KEY="sec_app_prod_xxxx"
You will get your unique live HTTPS endpoint:
https://run.litelambda.in/w/razorpay-webhook/live_sec_09fa84b
2. Configure Webhook in Razorpay Dashboard
- Log into your Razorpay Dashboard.
- In the left navigation, navigate to Account & Settings → Webhooks.
- Click + Add New Webhook.
- Paste your LiteLambda HTTPS URL into the Webhook URL field.
- In Secret, enter the identical secret string you set in your
RAZORPAY_WEBHOOK_SECRETenvironment variable. - Under Active Events, select:
payment.authorizedpayment.capturedpayment.failedorder.paid- (Optional)
subscription.chargedif using Razorpay Subscriptions. - Click Create Webhook.
Critical Best Practices for Payment Webhooks
1. Respect the 5-Second Timeout Window
Razorpay expects an HTTP 200 response within 5 seconds. If your handler takes longer because you are generating a complex PDF invoice synchronously or calling slow third-party APIs, Razorpay flags the delivery as failed and retries.
The Fix: Use LiteLambda's asynchronous execution or run PDF generation in a background job. LiteLambda returns an instant acknowledgement to Razorpay while processing the business logic safely in the background.
2. Always Store the payment_id for Idempotency
Razorpay guarantees at-least-once delivery. If a network blip occurs after your handler runs but before Razorpay receives your HTTP response, Razorpay sends the same webhook again. Always check if the payment_id has already been recorded before fulfilling the order.
3. Handle Amount in Paisa
Razorpay passes amounts in the smallest currency unit. For Indian Rupee (INR), amount: 49900 means ₹499.00. Never credit a user with ₹49,900 by accident.
Comparison: Where Should You Host Razorpay Webhooks?
| Platform | Setup Complexity | Cold Starts | Idempotency Support | Cost |
|---|---|---|---|---|
| LiteLambda | 60 seconds (litelambda deploy) |
Zero (< 30ms latency) | Built-in KV store | $4.99/mo flat |
| AWS Lambda + API Gateway | High (IAM, Route53, API Gateway) | 800ms–2s cold start | Requires DynamoDB setup | Pay-per-ms + Gateway fees |
| Self-Hosted VPS (EC2/Droplet) | High (Nginx, SSL, UFW, Systemd) | No cold start | Requires Redis container | $10–$20/mo idle cost |
| Heroku / Render | Medium (Procfile, Dynos) | Free tier sleeps (unacceptable) | Requires Redis add-on | $7–$25/mo |
Summary
Handling payment events properly is the difference between smooth, automated operations and endless support tickets asking "I was debited ₹999, why is my account still on free?".
By combining Python's native HMAC verification with LiteLambda's zero-maintenance serverless execution, you get an unshakeable payment processing pipeline ready for any volume of transactions.