DEV Community

Ranveer Singh
Ranveer Singh

Posted on

Verifying Razorpay subscription webhooks in ASP.NET Core (the parts that bit me)

Razorpay's subscription webhooks look simple: receive JSON, update the plan. In practice three things break first — signature checks, duplicate deliveries, and testing locally. Here's how I handle each in .NET 10.

  1. Verify against the raw body, with the webhook secret

The signature in X-Razorpay-Signature is an HMAC-SHA256 of the raw request body. If you let model binding parse it first and re-serialize, the bytes change and verification fails. Also: the webhook secret is the one you set in the dashboard webhook settings — not your API key secret. Mixing them up is the most common mistake.

csharp
app.MapPost("/api/billing/webhook", async (HttpRequest req, IOptions opt, BillingService billing) =>
{
using var reader = new StreamReader(req.Body);
var body = await reader.ReadToEndAsync();

var expected = Convert.ToHexString(
    HMACSHA256.HashData(Encoding.UTF8.GetBytes(opt.Value.WebhookSecret), Encoding.UTF8.GetBytes(body)))
    .ToLowerInvariant();
var actual = req.Headers["X-Razorpay-Signature"].ToString();

if (!CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(actual)))
    return Results.Unauthorized();

await billing.HandleAsync(req.Headers["x-razorpay-event-id"].ToString(), body);
return Results.Ok();
Enter fullscreen mode Exit fullscreen mode

});

FixedTimeEquals avoids leaking timing information on comparison.

  1. Assume every event arrives more than once

Razorpay retries when you don't return 2xx quickly, so the same subscription.charged can land twice. Store the event ID with a unique index and skip anything already seen. Do the insert and the state change in one transaction.

  1. Handle the lifecycle, not just "paid"

The events that matter:

subscription.activated
subscription.charged
subscription.pending — payment failed, Razorpay is retrying
subscription.halted — retries exhausted
subscription.cancelled

Map them to your own status enum and gate features on your status — never call Razorpay on every request.

  1. Test with real webhooks locally

Use test mode plus a tunnel (cloudflared or ngrok) pointed at your local API. Mocks won't catch the raw-body or secret mistakes above.

I packaged this (plus multi-tenancy, auth and a React frontend) into a .NET 10 SaaS starter — early access here.

Questions about the webhook flow? Ask in the comments.

Top comments (1)

Collapse
 
fintechrailsdigest profile image
Fintech Rails Digest •

the raw-body re-serialization trap is the one that catches almost everyone, and it usually only surfaces in production because test tools keep the body pristine. the other ordering detail worth naming: subscription.charged and the corresponding webhook arrive more than once by design, so treating the event id as an idempotency key in the same handler that updates the status enum keeps a retried delivery from double-firing the provisioning step. mapping to your own status enum before gating features is the part I'd put first in any integration checklist.