Building a payment integration is easy in a tutorial.
Making it actually work is where the real engineering begins. ๐
While working on my ShopEase e-commerce project, I reached a point where payment creation and verification were already working. The next challenge was handling Razorpay Webhooks and processing payment events asynchronously.
๐๏ธ My Payment Architecture
ShopEase uses a microservices-based backend, with a separate payment-service running on port 8085.
๐ Before Webhooks
๐ Frontend
โ
๐ API Gateway
โ
๐ณ Payment Service
โ
๐ฐ Razorpay
โก After Adding Webhooks
๐ฐ Razorpay
โ
๐ Webhook
โ
๐ณ Payment Service
โ
๐ Verify Signature
โ
๐ Identify Event
โ
๐๏ธ Update Payment
โ
๐พ Store Event ID
๐ตโ๐ซ Challenge #1 โ Duplicate Webhooks
One of the first things I realized was that a webhook may be delivered more than once.
If I process the same event twice, I could accidentally execute the same business logic multiple times.
So I introduced a webhook_events table and stored the webhook's eventId.
๐ Why?
Webhook received
โ
Already processed?
โ โ
YES NO
โ โ
Ignore Process
โ
Save Event ID
This gave my webhook processing idempotency.
๐ Challenge #2 โ Webhook Secret โ API Secret
This one was important.
I learned that the Razorpay Webhook Secret is different from the Razorpay API Key Secret.
Instead of hardcoding the secret, I kept it in an environment variable:
razorpay.webhook.secret=${RAZORPAY_WEBHOOK_SECRET}
๐ Never put actual secrets directly into your GitHub repository.
๐ก๏ธ Challenge #3 โ Verifying the Webhook Signature
I couldn't simply trust every request that reached my endpoint.
The request contains:
X-Razorpay-Signature
I used Razorpay's signature verification mechanism with the raw request payload and my webhook secret.
๐จ Incoming Request
โ
๐ Extract Signature
โ
๐งฎ Generate/Verify HMAC
โ
Valid? โโโโโโ No โ โ Reject
โ
Yes
โ
โ
Process
๐ซ Challenge #4 โ The Mysterious 403 Forbidden
This was one of those errors where I initially thought:
"Controller mein hi kuch problem hai." ๐
But the controller wasn't the problem.
Spring Security was blocking the request.
My normal APIs require authentication, but Razorpay's webhook doesn't have my application's JWT.
So I specifically permitted:
.requestMatchers("/payments/webhook").permitAll()
and disabled CSRF for the API.
๐ก Lesson
A perfectly written controller is useless if security blocks the request before it reaches the controller.
๐งช Challenge #5 โ Testing Without Razorpay
While testing locally through Postman, I initially didn't have the real Razorpay signature.
A fake signature such as:
test-signature
correctly resulted in:
401 Unauthorized
And honestly, that was good news. ๐
It meant my signature verification was actually doing its job.
So I created a Postman Pre-request Script to generate the HMAC-SHA256 signature automatically whenever I changed the webhook payload.
๐ซ Challenge #6 โ "Payment Record Not Found"
This was probably one of my most interesting debugging moments.
The signature was valid โ
The event was recognized โ
But:
{
"success": false,
"message": "Payment record not found"
}
๐ Root Cause
The Razorpay Order ID didn't match the Order ID stored in my database.
It was literally a tiny character difference. ๐ญ
Postman:
order_TT9CFIQhKkyXwR
Database:
order_TT9CFlQkHkyXwR
For humans, they look almost identical.
For a database?
Completely different values. ๐
After correcting the Order ID, the webhook successfully found the payment.
๐ณ Challenge #7 โ XAMPP vs Docker MySQL
This one caused a lot of confusion.
My application was using:
localhost:3308
while I was checking MySQL through XAMPP/phpMyAdmin on:
localhost:3306
I eventually discovered that port 3308 was actually mapped to my Docker MySQL container:
Windows :3308
โ
๐ณ Docker
โ
MySQL :3306
The container was:
shopease-mysql
mysql:8.4
0.0.0.0:3308 โ 3306
So instead of trying to move XAMPP to port 3308, I connected directly to the Docker MySQL container.
๐ง Biggest Lesson Here
Before changing a port, first find out which process is already using it.
โ Testing payment.captured
After fixing the database issue, I tested:
payment.captured
The webhook returned:
{
"success": true,
"message": "Webhook signature verified",
"event": "payment.captured"
}
with:
HTTP 200 OK
More importantly, I didn't stop at the API response.
I checked the database and confirmed:
CREATED
โ
SUCCESS
That confirmed the actual business logic worked, not just the HTTP response.
๐ Testing Idempotency
Then came the real test.
I sent the exact same webhook again using the same event ID.
Instead of processing it again, the application returned:
{
"success": true,
"message": "Webhook already processed",
"event": "payment.captured"
}
๐ฏ Duplicate processing successfully prevented!
โ Testing payment.failed
I also tested:
payment.failed
The webhook was successfully processed and the database payment status changed to:
FAILED
So both major payment events were working:
Event Result
๐ฐ payment.captured โ
SUCCESS
โ payment.failed โ
FAILED
๐ Duplicate event โ
Blocked
๐ Invalid signature โ
Rejected
๐ Final Architecture
๐ฐ Razorpay
โ
โ ๐ Webhook
โ
POST /payments/webhook
โ
โ
๐ก๏ธ Spring Security
โ
โ
๐ Signature Check
โ
โ
๐ Event Detection
โ โ
payment.captured payment.failed
โ โ
Find Payment Find Payment
โ โ
SUCCESS FAILED
โ โ
โ
๐ Check Event ID
โ
Already Processed?
โ โ
YES NO
โ โ
Ignore ๐พ Save Event
โ
โ
200 OK
๐ง What This Actually Taught Me
The biggest lesson wasn't just "how to implement Razorpay Webhooks."
It was learning how to debug a complete backend system.
๐ 1. Security matters
A controller can't help if Spring Security blocks the request.
๐ฏ 2. IDs must match exactly
A single character difference can break a database lookup.
๐ณ 3. Understand your infrastructure
Docker port mapping can completely change where your application is actually connecting.
๐ 4. Webhooks must be idempotent
The same event shouldn't execute your business logic multiple times.
๐๏ธ 5. Verify the database
200 OK doesn't necessarily mean your business logic worked.
๐ 6. Debugging is part of development
๐ป Code
โ
๐งช Test
โ
โ Error
โ
๐ Find Root Cause
โ
๐ง Fix
โ
๐งช Test Again
โ
๐๏ธ Verify Database
โ
โ
Done
๐ Current Status
My Razorpay Webhook implementation currently supports:
โ
Webhook endpoint
โ
Signature verification
โ
payment.captured
โ
payment.failed
โ
Payment status updates
โ
Duplicate webhook detection
โ
Idempotent processing
โ
Database verification
The next step is to extend the webhook flow beyond payment status updates and connect it with the complete post-payment business logic, such as order confirmation and cart clearing.
๐ก Final Takeaway
Implementing Razorpay Webhooks taught me something important:
Real-world backend development isn't just about writing code. It's about understanding what happens when that code meets security, databases, infrastructure, external APIs, and unexpected errors.
The most valuable part of this implementation wasn't simply making the webhook work.
It was debugging:
403 errors โ invalid signatures โ incorrect Order IDs โ Docker/MySQL conflicts โ duplicate events โ database verification. ๐๐ฅ
And that's what made this feature much more realistic than simply following an integration tutorial.
๐ ShopEase โ Payment Service
๐ณ Razorpay Webhook Integration ๐
Learn โ Build โ Break โ Debug โ Fix โ Improve. ๐ป๐ฅ

Top comments (1)
The one worth putting on a wall: never re-serialize the payload before verifying the signature. If the parser rebuilds the body and you compute the HMAC over that, key order and number formatting change the bytes, and a perfectly valid event fails the check โ it looks like a wrong secret when it's actually a rewritten message. Verify over the raw request bytes first, parse afterwards.
Second: dedup by event id is the right first guard, but it isn't idempotency, it's amnesia. Retries can carry a different event id for the same payment, and events arrive out of order โ captured before created is normal. The durable version is a state machine on the payment: SUCCESS is terminal, so a late FAILED gets refused loudly rather than overwriting. And return the 200 early; a slow handler turns one webhook into five retries.