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)

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.