Stripe in C#, part 6: Webhooks — every case, one endpoint (signatures, catalog, idempotency, retries)
Part 6from the Stripe in C# series · 6 parts in all
This is the article the series was built around. Webhooks are how Stripe tells you the things that happen when your code is not in the room: the 3DS challenge finished at 2am, the refund landed, the dispute opened, the invoice retry failed. Every serious integration is synchronous calls + this asynchronous feed. We will build one endpoint that handles all the cases: signature verification (two ways, including hand-rolling the HMAC), the event catalog, idempotency, and Stripe's retry behavior.
1. Why raw bytes are the whole ballgame
Stripe signs each delivery with a key you get when you register the endpoint
(whsec_..., separate per test/live). The
Stripe-Signature header carries a timestamp and signatures:
t=1738012800,v1=5257a869e7.... The v1 signature is
HMAC_SHA256(secret, "{t}.{rawBody}") — HMAC over the exact bytes
Stripe sent. Any re-serialization (your framework re-parsing and re-printing
JSON, altering whitespace or property order) changes the bytes and the check fails. So
rule zero: read the request body as bytes before anything else, and verify against those
bytes.
2. The easy way: Stripe.net verifies in one call
[HttpPost("stripe/webhook")]
public async Task<IActionResult> Handle()
{
// ASP.NET Core: [EnableBuffering] + leaving the body unread is the trick
// that keeps the raw payload available to the verifier below.
Request.EnableBuffering();
using var reader = new StreamReader(Request.Body, leaveOpen: true);
var json = await reader.ReadToEndAsync();
Request.Body.Position = 0;
try
{
// ConstructEvent checks: timestamp within tolerance (default 300s,
// anti-replay) AND the v1 HMAC over "{t}.{json}". Throws on mismatch.
var evt = EventUtility.ConstructEvent(
json, Request.Headers["Stripe-Signature"],
_config["Stripe:WebhookSecret"]); // whsec_..., per-endpoint!
HandleEvent(evt); // dispatch, section 5
return Ok(); // 2xx = accepted
}
catch (StripeException) { return BadRequest(); } // bad signature
}
3. The instructive way: hand-rolled HMAC (no SDK)
Doing it by hand once teaches what "signed webhook" actually means. This is the shape of our generic receiver — an isolated-worker Azure Function on .NET 10, anonymous by function auth because the signature is the authentication:
using System.Security.Cryptography;
using System.Text;
// 1. Raw body FIRST - before any JSON parsing, before anything reserializes.
byte[] body = await ReadAllBytesAsync(request.Body); // exact bytes
string payload = Encoding.UTF8.GetString(body);
string sigHeader = request.Headers["Stripe-Signature"]; // "t=...,v1=..."
// 2. Parse "t=1738012800,v1=abc,v1=def" (multiple v1 values possible).
var parts = sigHeader.Split(',', StringSplitOptions.RemoveEmptyEntries);
string t = parts.Select(p => p.Split('=', 2))
.FirstOrDefault(kv => kv[0] == "t")?[1]
?? throw new UnauthorizedAccessException("missing timestamp");
var provided = parts.Where(p => p.StartsWith("v1=", StringComparison.Ordinal))
.Select(p => p[3..]).ToArray();
// 3. Replay window: refuse stale timestamps (300s is Stripe's default too).
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(t)) > 300)
return new BadRequestResult(); // 400: do not process old deliveries
// 4. The check: HMAC over exactly "timestamp.body", hex-lowercase compare.
byte[] key = Encoding.UTF8.GetBytes(webhookSecret); // whsec_... value
byte[] signed = Encoding.UTF8.GetBytes($"{t}.{payload}");
string expected = Convert.ToHexString(HMACSHA256.HashData(key, signed))
.ToLowerInvariant();
bool ok = provided.Any(sig =>
CryptographicOperations.FixedTimeEquals( // constant time!
Encoding.UTF8.GetBytes(sig),
Encoding.UTF8.GetBytes(expected)));
if (!ok) return new BadRequestResult(); // forged or corrupted - reject
// 5. ONLY NOW parse JSON and dispatch by event id + type.
var evt = JsonSerializer.Deserialize<StripeEvent>(payload)!;
return await DispatchAsync(evt); // section 5 - always 200 once verified
The two details people skip and later regret: the constant-time
comparison (CryptographicOperations.FixedTimeEquals, never
==) closes timing oracles; and the tolerance window closes
replay attacks — a signature is only valid for five minutes, so capturing one does not
capture an endpoint forever.
4. The event catalog: every case your commerce app will meet
Stripe's event catalog is long, but commerce code really lives in these families. The receiver below switches by category — log everything, act on what your domain needs:
| Family | Events | Do |
|---|---|---|
| payment_intent.* | succeeded, payment_failed, requires_action, canceled, amount_capturable_updated | fulfill / notify / unblock inventory / release auth |
| charge.* | succeeded, refunded, updated, capture | receipt trail; refunded pairs with Refund API events |
| refund.* | created, updated | track refund completion, notify customer |
| charge.dispute.* | created, updated, funds_withdrawn, funds_reinstated, closed | open case + clock, submit evidence, record outcome |
| payment_method.* | attached, detached, automatically_updated, card_automatically_updated | update saved-card UI state |
| customer.* | created, updated, deleted | mirror CRM records |
| customer.subscription.* | created, updated, deleted, trial_will_end, paused, resumed, pending_update_applied | entitlements on/off, dunning UI, trial-expiry email |
| invoice.* | upcoming, finalized, payment_succeeded, payment_failed, marked_uncollectible | dunning, access removal, revenue ledger |
| setup_intent.* | succeeded, setup_failed | saved-card confirmation flows |
| checkout.session.* | completed, expired, async_payment_succeeded, async_payment_failed | fulfill guest checkouts (no customer object yet); handle vouchers/bank debits that settle late |
| payout.* / balance.available | payout.paid, payout.failed | treasury reconciliation |
// The dispatch skeleton (category switch, log-everything, act-selectively).
// Business logic goes in the cases you care about; everything else 200s.
Task<IActionResult> DispatchAsync(StripeEvent evt)
{
log.Information("event {Id} {Type} v={Version}", evt.Id, evt.Type, evt.ApiVersion);
switch (evt.Type)
{
case "payment_intent.succeeded":
var pi = evt.Data.Object as PaymentIntent;
FulfillOrder(pi!.Metadata["orderId"]); // idempotent, section 5
break;
case "payment_intent.payment_failed":
NotifyCustomerOfFailure(evt);
break;
case "charge.dispute.created":
OpenDisputeCase(evt); // start the evidence clock TODAY
break;
case "invoice.payment_failed":
case "customer.subscription.deleted":
SuspendEntitlements(evt);
break;
case "checkout.session.completed":
FulfillGuestCheckout(evt);
break;
default:
log.Information("unhandled category {Type} - acknowledged", evt.Type);
break;
}
return Task.FromResult<IActionResult>(new OkObjectResult(new { received = true }));
}
5. The reliability contract (read this twice)
- 200 fast, work later. Verify, enqueue, 200. Stripe's timeout is about 10 seconds; do not fulfill orders inside the request. Slow handlers cause retries, retries cause duplicate handling, duplicates cause double fulfillment.
- Idempotency by event id. Store
event.id(unique key in a table) before processing; if it is already there, return 200 immediately. Stripe will redeliver. - Retries are a feature. No 2xx within the window (or an endpoint error) makes Stripe retry with exponential backoff for up to three days, then disable the endpoint. Never 4xx an event to "reject" it — that just schedules retries; return 200 and ignore what you do not handle.
- Version pinning. Events carry
api_version; the SDK's typed payloads match the version your package pins. Upgrade deliberately. - Test vs live secrets differ.
whsec_values are per-endpoint per-mode; the classic outage is copying the live secret into the test config.
6. Testing without waiting for real money
# The Stripe CLI forwards real signed events to localhost...
stripe listen --forward-to localhost:7071/api/stripe/webhook
stripe trigger payment_intent.succeeded
stripe trigger charge.dispute.created
# ...and you can hand-sign synthetic events too (same HMAC the receiver
# checks): build "{t}.{body}", HMAC with the whsec_ secret, set the header.
The checklist
One endpoint (or one per surface), raw bytes first, verify signature + tolerance, idempotency table, enqueue-and-200, category switch with a logged default, dashboard monitoring for the retry/endpoint-health graph. That is every case. With payments covered, this series pivots next to Shopify — the other half of MapleCart's stack, where the GraphQL admin API replaces Stripe's REST with a very different set of verbs.