Skip to content
Skip to main content
Novel Systems home
Decision log
D-027August 4, 2026

The webhook destination is subscribed to exactly what the code branches on, and the old one pointed at nothing

Decided

The Stripe event destination we_1Tzp6bPiVC0ozAWK1OLu8cdN was *edited in place* to point at https://api.novelsystems.ca/api/v1/integrations/stripe/webhook and subscribed to exactly four events — checkout.session.completed, checkout.session.expired, invoice.paid, invoice.payment_failed — which is precisely the set handleStripeEvent branches on. payment_intent.succeeded was removed from the subscription.

What was actually wrong. The pre-existing destination pointed at https://novelsystems.ca/api/webhooks/stripe. That path exists in neither application: app/api/ on the frontend contains only careers, contact, cpq, health, quote and support, and the backend mounts its handler under /api/v1/integrations/. The destination's delivery counters read Total 0 / Failed 0 — it had never fired, so no payment signal was lost. But a status row claiming a working Stripe webhook would have been false, and it is recorded here rather than quietly corrected.

Why edit rather than add. Creating a second, correct destination would have left the dead one live and subscribed. Two destinations for one integration is how an at-least-once delivery system becomes an at-least-twice one the first time somebody re-enables the wrong one. There is one destination.

Why narrow the event list. Stripe delivers every event type the destination is subscribed to, and handleStripeEvent returns handled:false for anything it does not recognise. Subscribing to events nothing handles does not break anything — it fills the delivery log with 200s that mean nothing, which is the same as having no delivery log. The subscription is a declaration of what the code does; it should be kept true the same way a type signature is.

How it was proved rather than assumed. Three observations, in order:

  1. 1.Before STRIPE_WEBHOOK_SECRET was set, a POST to the endpoint returned 503 internal_error — the guard at the top of stripeWebhook.
  2. 2.After the secret was saved and the deployment redeployed, the same POST with a deliberately bogus Stripe-Signature returned 400 bad_request "Signature verification failed." (request_id 113e54ce-e89b-4e10-a16d-997d96d7fd60). That branch is only reachable from inside verifyStripeSignature, so the 503→400 transition is proof the variable reached the running process — not proof that it was typed correctly into a form.
  3. 3.A real signed event — stripe trigger invoice.paid, event evt_1U0dltPiVC0ozAWKLE5BQVAD, 2026-08-04 08:32:26 UTC — was Delivered, HTTP 200, body `{"received":true,"handled":false}`.

What this does not prove. handled:false is correct and expected: the trigger created an invoice this platform did not issue, so resolveByExternalId("STRIPE", invoiceId) finds no quote. Transport, HMAC verification and dispatch are proved live. The *write* — updateMany guarded on paidAt: null — remains covered by integration test only, because POST /api/v1/quotes/{id}/checkout-session sits behind requireAuth, requireFinancialAccess and the only seeded identity's password is a Sensitive value in the frontend Vercel project that is not mine to read. That is a gap in *live* coverage and is stated as one rather than rounded up.

What would make this wrong. Adding a handler branch without adding the event to the subscription — the code would be correct and the event would never arrive. The subscription list and the switch in handleStripeEvent are two copies of one fact and nothing currently checks that they agree.