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.
- 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();
});
FixedTimeEquals avoids leaking timing information on comparison.
- 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.
- 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.
- 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)
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.