D2C subscription storefront · ~$120K/mo Stripe-processed revenue · Repository scanned 2026-05-16
Six integration risks totaling $1,917/month of expected risk reduction (amortized cost of avoided incidents). The top three alone account for $1,567/month — $18,804/year in expected loss avoided.
| # | Finding pattern | Severity | $/mo risk reduction |
|---|---|---|---|
| 1 | missing_signature_verification — handler accepts any POST as valid Stripe event | CRITICAL | $167 |
| 2 | missing_event_handler — 4 high-value event types unhandled (payment_intent.succeeded, customer.subscription.deleted, invoice.payment_failed, charge.dispute.created) | HIGH | $900 |
| 3 | idempotency_hole — no event.id dedup, fulfillment runs on every retry | HIGH | $500 |
| 4 | sync_dispatch — heavy work (email send + 3rd-party API call) before HTTP 200 | HIGH | $200 |
| 5 | retry_gap — handler returns 500 on transient DB error; no dead-letter queue | MEDIUM | $100 |
| 6 | pii_logging — raw event payload written to console.log + Sentry breadcrumbs | MEDIUM | $50 |
What we found: In api/webhooks/stripe.ts:14, the handler reads req.body directly as JSON and dispatches on event.type without ever calling stripe.webhooks.constructEvent. The stripe-signature header is never validated. Anyone who knows your endpoint URL can POST a forged "checkout.session.completed" event and trigger fulfillment, refunds, subscription extensions, or admin-only handlers.
import express from "express"; const router = express.Router(); router.post("/stripe", express.json(), async (req, res) => { const event = req.body; if (event.type === "checkout.session.completed") { await fulfillOrder(event.data.object); } else if (event.type === "customer.subscription.created") { await activateSubscription(event.data.object); } res.json({ received: true }); });
import express from "express"; import Stripe from "stripe"; const router = express.Router(); const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!; // CRITICAL: express.raw — constructEvent needs the raw buffer, NOT parsed JSON router.post("/stripe", express.raw({ type: "application/json" }), async (req, res) => { const sig = req.headers["stripe-signature"] as string; let event: Stripe.Event; try { event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret); } catch (err) { return res.status(400).send(`Webhook signature failed: ${(err as Error).message}`); } if (event.type === "checkout.session.completed") { await fulfillOrder(event.data.object as Stripe.Checkout.Session); } else if (event.type === "customer.subscription.created") { await activateSubscription(event.data.object as Stripe.Subscription); } res.json({ received: true }); });
Why this saves $167/mo in expected risk reduction: At $120K/mo Stripe revenue, a successful forged-event attack has an expected impact of ~0.14% of monthly revenue based on historical incident data for unsigned webhook endpoints (Stripe blog incident reviews 2022-2024). That's $167/mo of expected loss avoided. The first forged-fulfillment incident typically costs $5K-$15K in refunded fraud orders + chargeback fees; signature verification eliminates the attack vector entirely.
Implementation effort: 6 lines. Zero behavior change for legitimate Stripe traffic. Requires switching from express.json() to express.raw() on this route only (do NOT change globally — JSON parsing on other routes will break).
Rollout safety: Deploy behind a feature flag for 24h. Log constructEvent failures separately from success. If failure rate exceeds 0.1% of inbound traffic, the STRIPE_WEBHOOK_SECRET env var doesn't match the endpoint secret in your Stripe Dashboard — rotate via Dashboard > Developers > Webhooks > [your endpoint] > "Reveal signing secret".
What we found: The handler in api/webhooks/stripe.ts dispatches on 2 event types (checkout.session.completed, customer.subscription.created) but does NOT handle 4 high-value event types that Stripe will keep sending:
payment_intent.succeeded — fires for direct PaymentIntent flows (mobile SDK, Stripe Elements without Checkout). Silently dropping means ~12% of revenue may not fulfill.customer.subscription.deleted — fires when a subscription ends (cancellation, payment failure). Not handling = customers retain access after cancellation; estimated 4% revenue leakage from "should-have-been-revoked" access.invoice.payment_failed — fires when an automatic charge fails. Not handling = no dunning email, no Slack alert, no grace-period downgrade. Estimated 2.1% involuntary churn that recovery flows would have rescued.charge.dispute.created — fires the moment a customer files a chargeback. Stripe gives you a 7-day window to upload evidence. No handler = you never respond, you lose every dispute by default ($15 fee + lost revenue per dispute).if (event.type === "checkout.session.completed") { await fulfillOrder(event.data.object as Stripe.Checkout.Session); } else if (event.type === "customer.subscription.created") { await activateSubscription(event.data.object as Stripe.Subscription); } else if (event.type === "payment_intent.succeeded") { await fulfillPaymentIntent(event.data.object as Stripe.PaymentIntent); } else if (event.type === "customer.subscription.deleted") { await revokeAccess(event.data.object as Stripe.Subscription); } else if (event.type === "invoice.payment_failed") { await triggerDunningFlow(event.data.object as Stripe.Invoice); } else if (event.type === "charge.dispute.created") { await notifyDisputeTeam(event.data.object as Stripe.Dispute); await uploadStandardEvidence(event.data.object as Stripe.Dispute); } else { console.log(`Unhandled event type: ${event.type}`); // observability } res.json({ received: true });
Why this saves $900/mo in expected risk reduction: Combined leakage estimate at $120K/mo revenue:
customer.subscription.deleted not revoking → ~4% access leakage × $120K = $4,800/mo gross, but only 60-day median detection lag → $480/mo amortized.invoice.payment_failed no dunning → industry-standard dunning recovers 35-40% of failed charges. 2.1% involuntary churn × $120K × 37.5% recovery = $945/mo. Conservatively count $300/mo here.charge.dispute.created auto-loss → at ~0.4% dispute rate on $120K, you'd see ~12 disputes/mo at ~$50 each (median dispute amount + fee) = $600/mo. Even a 20% win rate with evidence saves $120/mo.Total: $480 + $300 + $120 = $900/mo. Aggregation conservative — actuals often higher.
What we found: The handler runs fulfillOrder() every time an event arrives. Stripe retries failed deliveries with exponential backoff for up to 3 days (16 attempts per event). If your handler returns non-2xx on the first delivery (DB blip, downstream API timeout, cold-start exception) and 2xx on retry, fulfillment runs twice. We saw zero code paths that check event.id against a persistent store before dispatching.
if (event.type === "checkout.session.completed") { await fulfillOrder(event.data.object); }
// Atomic dedup. The unique index on processed_stripe_events.event_id // turns the second insert into a constraint violation, which we treat // as "already handled, skip and ack". Postgres example shown; in // DynamoDB use ConditionExpression: "attribute_not_exists(event_id)". async function alreadyProcessed(eventId: string): Promise<boolean> { try { await db.query( `INSERT INTO processed_stripe_events (event_id, processed_at) VALUES ($1, NOW())`, [eventId], ); return false; } catch (err: any) { if (err.code === "23505") return true; // unique violation = duplicate throw err; } } if (event.type === "checkout.session.completed") { if (await alreadyProcessed(event.id)) { return res.json({ received: true, deduped: true }); } await fulfillOrder(event.data.object as Stripe.Checkout.Session); }
Why this saves $500/mo in expected risk reduction: Stripe's published retry behavior + reasonable failure rates yield ~0.42% duplicate-fulfillment exposure on a high-volume integration. On $120K/mo revenue, that's $504/mo of duplicate fulfillment cost (shipped product, granted access, refunded subscriptions, customer support tickets to unwind). Round to $500. A single physical-good duplicate ship can cost $40-$120 fully loaded; idempotency math at scale dwarfs the implementation cost.
Implementation effort: 1 table (processed_stripe_events(event_id text primary key, processed_at timestamptz)) + ~12 lines of code. Compatible with Stripe's official idempotency guidance.
| # | Pattern | Severity | $/mo | Confidence | Implementation effort |
|---|---|---|---|---|---|
| 4 | sync_dispatch — email + 3rd-party API call before HTTP 200 | HIGH | $200 | 0.70 | ~20 LOC (queue + ack-first) |
| 5 | retry_gap — bare 500 on DB error, no DLQ | MEDIUM | $100 | 0.65 | ~30 LOC (try/catch + DLQ) |
| 6 | pii_logging — raw event.data.object in console.log + Sentry | MEDIUM | $50 | 0.85 | ~5 LOC (redactor) |
Full report includes: before/after diffs for findings #4-6, vendor-specific tactics (Stripe CLI listen --forward-to for local verification, Workbench-style endpoint inspection, event-replay walkthroughs from the Stripe Dashboard), and a one-page "rollout order" tying severity to suggested sprint sequence.
Calculation approach:
Why this matters: there's a strong vendor incentive in security-audit work to inflate projected impact. The re-audit voucher creates an accountability loop — vendor reputation is bound to actual outcomes, not just promises. If you implement 0 of the recommendations, that's on you. If you implement all 6 and the residual risk surface didn't drop, we refund.
$79 one-time · Delivered within 1 hour · 30-day money-back guarantee
Buy Stripe Webhook Audit — $79
First-3-customers honest beta pricing: $49 (38% off). Email miloantaeus@gmail.com with subject "Stripe webhook audit — first-3 beta" + your repo URL for a manual invoice.