Die ene vrijdagmiddag dat onze Stripe webhooks stopten
Een eerlijk verhaal over een webhook-integratie die misgaat op het slechtst mogelijke moment, en wat ik daarvan heb geleerd over het debuggen van webhooks.
Jean-Pierre Broeders
Freelance .NET & DevOps Engineer
16:32, vrijdagmiddag
Mijn telefoon ging. Het was Marco, de CTO van een e-commerce klant waar ik al een paar maanden aan hun betalingsinfrastructuur werkte. Niet het telefoontje dat je wilt op vrijdagmiddag.
"Jean-Pierre, er klopt iets niet. Klanten betalen, maar hun bestellingen blijven op 'pending' staan. We hebben er nu al zes."
Zes bestellingen. Mensen die gewoon geld hebben overgemaakt, maar geen bevestiging krijgen. Geen e-mail, geen statusupdate, niks. Het dashboard van de webshop liet de orders zien alsof er nooit betaald was.
Mijn eerste reactie: Stripe checken. Als de betalingen binnenkomen bij Stripe maar de webhooks niet aankomen bij onze applicatie, dan weten we waar het probleem zit.
Ik opende het Stripe dashboard, en ja hoor, alle payments stonden op succeeded. Het geld was binnen. Maar de webhook events lieten een ander verhaal zien: een muur van rode HTTP 400 responses. Elke webhook die Stripe naar ons stuurde, werd afgewezen.
De zoektocht begint
Mijn eerste instinct was de logs checken. Ik SSH'te naar de productieserver en begon door de applicatielogs te scrollen. Niets. Geen enkele webhook request te zien in onze logs. Dat was vreemd. Een 400 response betekent dat onze applicatie de request wél ontving, maar hem afwees. Waarom logden we dat dan niet?
Ik checkte de webhook controller:
[ApiController]
[Route("api/webhooks/stripe")]
public class StripeWebhookController : ControllerBase
{
private readonly string _webhookSecret;
public StripeWebhookController(IConfiguration configuration)
{
_webhookSecret = configuration["Stripe:WebhookSecret"]!;
}
[HttpPost]
public async Task<IActionResult> HandleWebhook()
{
var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();
try
{
var stripeEvent = EventUtility.ConstructEvent(
json,
Request.Headers["Stripe-Signature"],
_webhookSecret
);
// Handle the event...
await ProcessEvent(stripeEvent);
return Ok();
}
catch (StripeException)
{
return BadRequest();
}
}
}
Zie je het? Die kale catch (StripeException) die gewoon een BadRequest() teruggeeft zonder iets te loggen. Dat was mijn code. Van drie maanden geleden. Ik had het geschreven met de gedachte "dit gaat toch nooit fout". Ja, precies.
Maar goed, we wisten nu dat de signature validatie faalde. De vraag was: waarom?
De signature puzzle
Stripe webhooks gebruiken een HMAC-SHA256 signature om te verifiëren dat het request echt van Stripe komt. Je krijgt een whsec_ secret wanneer je een webhook endpoint configureert, en elke request bevat een Stripe-Signature header die je daarmee valideert.
Het probleem was duidelijk zodra ik het doorhad: eerder die dag had een collega een deployment gedaan. Niet van de applicatie zelf, maar van de infrastructure-as-code. Daarbij was de Stripe webhook endpoint opnieuw aangemaakt via Terraform, en Terraform had daarbij een nieuwe webhook signing secret gegenereerd.
De nieuwe secret stond netjes in de Terraform state. Maar niemand had de applicatie-configuratie bijgewerkt. Onze .NET applicatie gebruikte nog de oude whsec_ waarde uit de environment variables.
Elke webhook die binnenkwam werd keurig door Stripe gesigned met de nieuwe secret, en onze applicatie probeerde die te valideren met de oude secret. Mismatch. 400. Stille dood.
De quick fix
De oplossing was uiteindelijk simpel. Environment variable updaten met de nieuwe secret en de applicatie herstarten:
# Update de webhook secret in de Azure App Service
az webapp config appsettings set `
--resource-group rg-ecommerce-prod `
--name app-ecommerce-api `
--settings Stripe__WebhookSecret="whsec_nieuwesecretwaarde"
# Herstart de applicatie
az webapp restart `
--resource-group rg-ecommerce-prod `
--name app-ecommerce-api
Binnen twee minuten kwamen de webhooks weer door. Stripe retry't gefaalde webhooks automatisch, dus de meeste "verloren" events werden alsnog afgeleverd. Voor de zes bestellingen die al te lang wachtten, schreef ik een snelle PowerShell script om de payments handmatig te reconciliëren:
# Haal alle recente succeeded payments op die niet verwerkt zijn
$apiKey = $env:STRIPE_SECRET_KEY
$headers = @{ "Authorization" = "Bearer $apiKey" }
$payments = Invoke-RestMethod `
-Uri "https://api.stripe.com/v1/payment_intents?limit=20" `
-Headers $headers
foreach ($payment in $payments.data) {
if ($payment.status -eq "succeeded") {
$orderId = $payment.metadata.order_id
# Check of de order al verwerkt is in ons systeem
$order = Invoke-RestMethod `
-Uri "https://api.ecommerce-klant.nl/api/orders/$orderId" `
-Headers @{ "X-Api-Key" = $env:INTERNAL_API_KEY }
if ($order.status -eq "pending") {
Write-Host "Order $orderId: betaald maar niet verwerkt. Fixing..."
Invoke-RestMethod `
-Method Put `
-Uri "https://api.ecommerce-klant.nl/api/orders/$orderId/confirm" `
-Headers @{ "X-Api-Key" = $env:INTERNAL_API_KEY } `
-Body (@{ paymentIntentId = $payment.id } | ConvertTo-Json) `
-ContentType "application/json"
Write-Host "Order $orderId: gefixt!" -ForegroundColor Green
}
}
}
Om 17:45 was alles opgelost. De zes klanten kregen hun bevestigingsmail, en Marco kon zijn weekend in. Ik had een biertje nodig.
Wat er echt fout ging
Achteraf gezien waren er meerdere dingen mis:
1. Geen logging bij signature failures. Dit is de meest voor de hand liggende. Als we een warning hadden gelogd bij elke StripeException, hadden we het probleem binnen minuten gevonden in plaats van na een uur zoeken. De verbeterde versie:
catch (StripeException ex)
{
_logger.LogWarning(
"Stripe webhook signature validation failed: {Message}. " +
"Check if the webhook signing secret is up to date.",
ex.Message
);
return BadRequest();
}
2. Geen monitoring op webhook failures. We hadden alerts moeten hebben op HTTP 400 responses op het webhook endpoint. Als we dat hadden gehad, waren we gewaarschuwd vóórdat klanten het merkten.
3. Infrastructure en applicatie config niet gekoppeld. De Terraform deployment had automatisch de nieuwe secret naar de App Service moeten pushen. Handmatige stappen in een deployment pipeline zijn tikkende tijdbommen.
4. Geen manier om webhooks te inspecteren. Dit is wat me het meest frustreerde. Ik kon niet zien wat Stripe naar ons stuurde. Ik kon de body niet bekijken, de headers niet inspecteren, de signature niet handmatig valideren. Ik was blind.
De ngrok-dans
Voordat ik de fix naar productie pushte, wilde ik hem lokaal testen. Dat klinkt logisch, maar met webhooks is dat makkelijker gezegd dan gedaan.
Mijn standaard workflow was: ngrok opstarten, de tijdelijke URL kopiëren, die in het Stripe dashboard plakken als test endpoint, een testbetaling doen, kijken of de webhook doorkomt, debuggen, en dan alles weer terugzetten. Elke keer opnieuw.
Dat werkt. In theorie. In de praktijk vergeet je de URL te updaten na een ngrok herstart. Of ngrok geeft je een andere poort. Of je free-tier sessie verloopt halverwege je debugging. Of je collega start ook ngrok op en nu wijzen jullie allebei naar verschillende lokale instances. Het is een workflow die net goed genoeg is om te gebruiken, en net slecht genoeg om je langzaam gek te maken.
Die vrijdagmiddag had ik geen zin in de ngrok-dans. Ik had de fix in mijn code, ik wist vrij zeker dat het zou werken, maar "vrij zeker" is niet het antwoord dat een CTO wil horen als het om betalingen gaat. Dus deed ik wat elke developer doet als het snel moet: ik pushte de fix naar een staging omgeving, triggerde handmatig een webhook via de Stripe CLI, en bad dat het zou werken.
Het werkte. Maar het voelde niet als een professionele manier om software te testen.
De tooling die ik wilde
Na dit incident ben ik gaan nadenken over wat ik eigenlijk nodig had. Logging is leuk, maar dat is reactief. Ik wilde iets waarmee ik proactief kon werken:
- Een vast endpoint dat ik altijd kan checken, zonder elke keer ngrok op te starten
- Volledige request history, inclusief headers, body en query parameters
- De mogelijkheid om webhooks te replayen naar mijn lokale machine, zonder te hoeven wachten tot Stripe opnieuw retry't
- Request bodies kunnen aanpassen voor ik ze replay, zodat ik edge cases kan testen
Dit is uiteindelijk waarom ik WebhookVault heb gebouwd. Niet omdat ik een product wilde lanceren, maar omdat ik dit probleem zelf keer op keer tegenkwam. Bij elke klant, bij elke webhook-integratie.
Met WebhookVault had ik tijdens dit incident simpelweg het dashboard geopend, de binnenkomende requests bekeken, en direct gezien dat de signature validation faalde. Ik had een request kunnen replayen naar mijn lokale development omgeving om de fix te testen, zonder te wachten op Stripe retries. Het verschil tussen een uur debuggen en vijf minuten.
Lessen voor je eigen webhook-integraties
Als je met webhooks werkt (en als je een moderne applicatie bouwt, werk je met webhooks), zijn dit de dingen die ik je mee wil geven:
Log alles, ook failures. Een kale catch block die een 400 teruggeeft is een recept voor nachtelijke telefoontjes. Log het, met context.
Monitor je webhook endpoints. Zet alerts op response codes. Een plotselinge stijging in 400s of 500s is bijna altijd een probleem dat je wilt weten voordat je klanten er last van hebben.
Test met echte payloads. Niet met zelfverzonnen JSON die "er een beetje op lijkt". Gebruik tooling waarmee je echte webhook requests kunt capturen en replayen. Het scheelt je uren debugging.
Koppel je infrastructure aan je applicatie config. Als een deployment een secret roteert, moet die secret automatisch bij je applicatie terechtkomen. Geen handmatige stappen.
Heb een reconciliatie-strategie. Webhooks kunnen falen. Dat is een feit. Zorg dat je een manier hebt om gemiste events te detecteren en te verwerken. Een PowerShell script dat je handmatig draait is beter dan helemaal niets.
Tot slot
Webhook-integraties lijken simpel. Je zet een endpoint op, je parst wat JSON, je doet iets met de data. Maar in productie, met echte klanten en echt geld, wordt elke aanname die je maakt getest. En meestal op vrijdagmiddag.
De volgende keer dat je een webhook endpoint schrijft, denk dan even aan dit verhaal. En test je signature validation. Echt.
Heb je ook weleens een webhook-nachtmerrie meegemaakt? Ik hoor het graag. Neem contact op. En als je webhook-integraties wilt debuggen zonder gek te worden, check dan WebhookVault.
