DEV Community

Shitanshu Jha
Shitanshu Jha

Posted on

๐Ÿ’ณ Implementing Razorpay Webhooks in Spring Boot ๐Ÿš€ Real Problems I Faced & How I Solved Them

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)

Collapse
 
raknaos profile image
Raknaos •

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.