Trust is earned, not given

A different perspective

2026-02-24 · Projects

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:

FamilyEventsDo
payment_intent.*succeeded, payment_failed, requires_action, canceled, amount_capturable_updatedfulfill / notify / unblock inventory / release auth
charge.*succeeded, refunded, updated, capturereceipt trail; refunded pairs with Refund API events
refund.*created, updatedtrack refund completion, notify customer
charge.dispute.*created, updated, funds_withdrawn, funds_reinstated, closedopen case + clock, submit evidence, record outcome
payment_method.*attached, detached, automatically_updated, card_automatically_updatedupdate saved-card UI state
customer.*created, updated, deletedmirror CRM records
customer.subscription.*created, updated, deleted, trial_will_end, paused, resumed, pending_update_appliedentitlements on/off, dunning UI, trial-expiry email
invoice.*upcoming, finalized, payment_succeeded, payment_failed, marked_uncollectibledunning, access removal, revenue ledger
setup_intent.*succeeded, setup_failedsaved-card confirmation flows
checkout.session.*completed, expired, async_payment_succeeded, async_payment_failedfulfill guest checkouts (no customer object yet); handle vouchers/bank debits that settle late
payout.* / balance.availablepayout.paid, payout.failedtreasury 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)

  1. 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.
  2. 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.
  3. 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.
  4. Version pinning. Events carry api_version; the SDK's typed payloads match the version your package pins. Upgrade deliberately.
  5. 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.