Why Razorpay webhooks fail silently — and what to do about it
Disclaimer: The content of this blog represents the personal opinions of the author and is provided for informational purposes only. The author makes no representations as to the accuracy or completeness of any information on this site. The author will not be liable for any errors, omissions, or delays in this information or any losses, injuries, or damages from its display or use.
You shipped your payment flow. Razorpay’s test mode worked fine. You went live.
Three days later, a customer emails support: “I paid ₹2,999 but my subscription isn’t active.”
You check your server logs. No webhook received. You check Razorpay’s dashboard. The payment succeeded. The webhook was sent. Your server never got it.
This is the most common webhook failure pattern in Indian fintech, and it happens more often than Razorpay’s documentation suggests.
Why webhooks fail silently
Razorpay fires a webhook and expects a 200 OK within a few seconds. If your server doesn’t respond in time — or returns anything other than a 2xx — Razorpay marks it as failed. What happens next depends on which event type it is and whether you have retries configured correctly.
The silent part: Razorpay’s default retry behaviour is not what most developers expect.
Razorpay retries failed webhooks, but:
- Retries happen at fixed intervals, not immediately
- After a certain number of failures, the webhook is abandoned
- There is no alert to you when this happens
- The payment still shows as successful in your dashboard
The result is a payment that succeeded from Razorpay’s perspective, but your system never processed it. No order confirmation. No subscription activation. No fulfilment. Just a confused customer and a support ticket.
The three most common failure scenarios
1. Your server was slow to respond
Razorpay has a timeout on webhook delivery. If your server takes too long to respond — perhaps because it’s running a database migration, under load, or just cold-starting a serverless function — Razorpay considers it a failure. Your code may have actually run successfully, but Razorpay retried anyway. Now you have duplicate processing.
2. Your server returned a non-2xx during a deploy
A rolling deploy, a brief 502 from your load balancer, a database connection error during startup. Any of these during the seconds Razorpay fires a webhook results in a missed event. This is especially common during the night when payment volume is low and maintenance is scheduled.
3. Signature verification failed and your server returned 400
Razorpay signs every webhook payload with HMAC-SHA256. If your verification code has a bug — wrong secret, wrong encoding, wrong header name — your server returns a 400 or 500. Razorpay retries. Your verification keeps failing. The event is eventually abandoned.
How to detect silent failures today
Without dedicated tooling, you have two options:
Option 1: Poll Razorpay’s API
Write a reconciliation job that runs every hour. Fetch all payments from Razorpay’s API for the past hour and compare them against payments recorded in your database. Any gap is a missed webhook.
import razorpay
from datetime import datetime, timedelta
client = razorpay.Client(auth=("key_id", "key_secret"))
one_hour_ago = int((datetime.now() - timedelta(hours=1)).timestamp())
payments = client.payment.all({"from": one_hour_ago, "count": 100})
for payment in payments["items"]:
if payment["status"] == "captured":
# check if this payment_id exists in your DB
if not your_db.payment_exists(payment["id"]):
# missed webhook — trigger processing manually
process_payment(payment["id"])
This is a reasonable safety net but it’s not real-time and it doesn’t catch partial failures.
Option 2: Check Razorpay’s webhook logs
Go to Razorpay Dashboard → Webhooks → your webhook → Delivery Attempts. You can see every attempt and whether it succeeded or failed. You cannot automate alerts from here, but it’s useful for debugging a specific incident.
The right architecture for reliable webhook processing
A production-grade webhook handler has three components:
1. Acknowledge immediately, process asynchronously
Your webhook endpoint should do exactly two things: verify the signature, then enqueue the event. Return 200 OK in under 500ms. Process the actual business logic in a background worker.
func handleWebhook(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
// verify signature
if !verifyRazorpaySignature(body, r.Header.Get("X-Razorpay-Signature")) {
w.WriteHeader(http.StatusUnauthorized)
return
}
// enqueue — do not process here
queue.Enqueue("webhook_events", body)
// acknowledge fast
w.WriteHeader(http.StatusOK)
}
2. Idempotent event processing
Your background worker must handle duplicate events safely. Razorpay may fire the same event twice — either due to their retry logic or because your server returned 200 but the connection dropped before Razorpay received it.
Use the payment_id or event_id as an idempotency key. Check if you’ve already processed it before doing anything.
func processPaymentEvent(event WebhookEvent) error {
// idempotency check
if alreadyProcessed(event.PaymentID) {
return nil // duplicate, skip safely
}
// mark as processing
markProcessing(event.PaymentID)
// do the actual work
activateSubscription(event.PaymentID)
sendConfirmationEmail(event.PaymentID)
// mark as done
markProcessed(event.PaymentID)
return nil
}
3. Dead letter queue with alerting
If processing fails after N retries, move the event to a dead letter queue and alert your on-call channel immediately. Do not silently discard.
func workerLoop() {
for event := range queue.Consume("webhook_events") {
err := processWithRetry(event, maxRetries: 5)
if err != nil {
deadLetterQueue.Enqueue(event)
alertOncall(fmt.Sprintf("Webhook processing failed: %s", event.PaymentID))
}
}
}
The specific Razorpay signature verification code that actually works
The most common cause of verification failures is subtle encoding issues. Here is the exact implementation:
import hmac
import hashlib
def verify_razorpay_webhook(payload_body: bytes, signature: str, secret: str) -> bool:
"""
payload_body: raw request body bytes — do NOT parse as JSON first
signature: value from X-Razorpay-Signature header
secret: your webhook secret from Razorpay dashboard
"""
expected = hmac.new(
key=secret.encode('utf-8'),
msg=payload_body, # raw bytes, not decoded string
digestmod=hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
Three things to get right:
- Use the raw request body bytes, not a re-serialised JSON string
- Use
hmac.compare_digestnot==to prevent timing attacks - The secret is the webhook secret from your Razorpay dashboard, not your API key
What to do when you find a missed webhook
If you find a missed payment through your reconciliation job:
- Check whether the event is in Razorpay’s delivery logs and why it failed
- If the payment is confirmed captured in Razorpay, process it manually using the payment ID
- Do not wait for Razorpay to retry — by the time you find it, retries may have been exhausted
- Alert the customer proactively — “Your payment was received, we’re activating your account now”
The customer experience of “we noticed a delay and fixed it” is dramatically better than waiting for them to contact support.
The bigger picture
Webhook reliability is infrastructure, not application code. The retry logic, dead letter queue, idempotency checks, signature verification, and monitoring — these are the same problems for every startup processing payments in India, regardless of whether you use Razorpay, PayU, or Cashfree.
Building this correctly takes time. We are building AparHub to handle it for you — so you can focus on your product instead of your webhook plumbing.
If you have been burned by a missed Razorpay webhook, join the early access list — we would love to hear what went wrong and make sure AparHub prevents it.