Reference · Detectors

10 checks on the boundary between Stripe and your app DB.

Every detector below is deterministic — we never invent numbers, never train models on your data. Some checks run on Stripe alone (delivery failures, expired coupons, expiring cards); the rest compare Stripe against the subscription CSV you upload. Either way, every finding in your PDF traces back to the exact Stripe row — and, where a CSV is involved, the cell that disagrees.

D1

Stuck-event silent failure

Stripe fired a state-changing event your application database never reflected. Either your webhook handler returned 200 and then crashed silently, or it never received the event and you missed it.

With a CSV attached we run full-confidence mode: for every state-changing event, we derive what your CSV should now look like (sub created means a row exists; payment succeeded means the row is marked paid) and emit a finding when reality disagrees. Without a CSV we run partial-confidence: any event still showing pending_webhooks > 0 after 24h is flagged as likely silent.

Inputs
Stripe events (key-only ok) · optional CSV for full confidence
Severity
Critical (payment-related events with missing customer) · High (subscription/customer events) · Medium (partial-confidence)
event_id
evt_1Q9k…
event_type
invoice.payment_succeeded
customer
cus_R8K2nL…
csv_match
(none — row missing)
age
13d
D2

Webhook delivery failure

The 503s, the timeouts, the silent 5xx’s. We compute a workspace-wide success ratio across every endpoint and every event type Stripe tried to deliver.

From Stripe’s events API: count drained events (delivered + acknowledged) versus total. Below 95% delivery success across the window we emit a workspace-wide finding. Per-endpoint findings emit at higher confidence when an individual endpoint has elevated failure.

Inputs
Stripe events + webhook_endpoints (key-only — no CSV required)
Severity
High (≥5% failure rate workspace-wide or per-endpoint)
endpoint
we_1P4n…
events_90d
4,127
failed_or_stuck
213
success_ratio
0.948
D3

Subscription state mismatch

Two failure modes. Leaked service: your DB says canceled but Stripe says active — they keep paying for something your app already shut off. Phantom paying: your DB says active but Stripe says canceled — they have access but you’re no longer billing them.

Join Stripe subscriptions to CSV rows by external customer id (or lowercased email). Compare normalized status. We currently flag active ↔ canceled in both directions; active ↔ past_due is left to D6 to avoid double-counting.

Inputs
Stripe subscriptions + CSV (required)
Severity
Critical (leaked_service — customer still paying) · High (phantom — access without billing)
customer
cus_4f9p…
stripe_status
active
csv_status
canceled
classification
leaked_service
paid_since_cancel
$58.00 (2 invoices)
D4

Plan / price drift

Your DB’s plan_code and Stripe’s active price ID don’t agree. A migration left one side behind, or a customer was manually moved on one side and not the other. Both numbers look plausible in isolation — the problem is they aren’t the same row.

We auto-derive a plan_code-to-price_id map per audit (modal price id per plan_code, sample ≥ 3 and modal share ≥ 0.70). For each subscription we check whether the CSV’s plan_code maps to the price Stripe is actually billing. Mismatches emit a finding.

Inputs
Stripe subscriptions + CSV with plan_code column + derived map
Severity
High
customer
cus_3r1q…
csv_plan_code
starter
expected_price
price_starter_monthly
stripe_price
price_pro_monthly
billed_amount
$89/mo
D5

Ghost customer

Stripe has them paying — recurring or one-time, at least $0.01 in the last 90 days — but your CSV has no row matching their email. Almost always a signup webhook that returned 200 and then silently failed to insert.

Email-only match. We lowercase every Stripe customer email and look for it in the CSV email column. Customers with paid invoices in the last 90 days and no match emit a finding. Active subscriptions are critical; one-time-only paid customers are high.

Inputs
Stripe customers + paid invoices + CSV (email-matched, lowercased)
Severity
Critical (active subscription, no row) · High (one-time only, no row)
customer
cus_R8K2nL…
email
jane@…
paid_90d_cents
17,400
csv_email_match
none
classification
active
D6

Dunning / payment-fail drift

Stripe has the subscription in past_due or unpaid — the card declined, Smart Retries fired, the customer didn’t fix it. Your DB still marks them active and they still have access to the product.

Per subscription, compare Stripe status against CSV status. Stripe past_due or unpaid + CSV active produces a finding. We don’t model retry counts or dunning age — the state of the sub in Stripe is the source of truth.

Inputs
Stripe subscriptions + CSV (required)
Severity
High
customer
cus_8t5d…
stripe_status
past_due
csv_status
active
last_payment_failed
47d ago
D7

Trial / period-end drift

Either the trial ended in Stripe and your DB still has them as trialing, or a renewal date drifted across the boundary. Churn signals, dunning logic, and renewal reminders all consume these fields — when they disagree, downstream code makes wrong decisions.

For each subscription: if status is trialing in either source, compare trial_end; otherwise compare current_period_end. A delta of more than 24 hours emits a finding.

Inputs
Stripe subscriptions + CSV (required)
Severity
High
customer
cus_6h4p…
stripe_trial_end
2026-04-12
csv_trial_end
2026-04-21
delta
9d (DB late)
D8

Expired coupon still applied

A promo code's redemption window closed, but the discount it created keeps applying to existing subscribers every cycle. The Black Friday code you never turned off is still taking 20% off in March.

For each active or trialing subscription, inspect its discounts. If the coupon's redeem_by has passed (or Stripe already marked it invalid) yet the discount is still attached, emit a finding. Estimated give-away = amount_off, or percent_off × the item's unit price.

Inputs
Stripe subscriptions + coupons (key-only — no CSV)
Severity
High
customer
cus_9x2k…
coupon
BLACKFRIDAY (20% off)
redeem_by
2025-11-30 (passed)
still_applied
yes · $10.00/mo
D9

Card expiring soon

An active subscription's card expires this month or next, with no updated card on file. The next renewal fails silently and you lose a paying customer to involuntary churn.

For each active or trialing subscription, read the default payment method's card expiry. If it falls in the current or next calendar month (or has already passed), emit a finding. At-risk revenue = the item's unit price × an involuntary-churn factor.

Inputs
Stripe subscriptions + payment methods (key-only — no CSV)
Severity
High
customer
cus_4f8p…
card
visa ···· 4242
expires
05/2026 (this month)
at_risk
$29.00/mo
D10

Uncollected subscription

A subscription Stripe still counts toward your MRR but is no longer collecting — status past_due (a payment failed) or unpaid (retries exhausted, service usually still on). It's revenue you're billing on paper while taking in nothing.

Across every non-canceled subscription, flag any whose status is past_due or unpaid — Stripe's own billing status is the truth, so no app DB is needed. past_due is High (still retrying); unpaid is Critical (retries done). At-risk revenue = the item's unit price × a recovery factor. This is the Stripe-only sibling of D6.

Inputs
Stripe subscriptions (key-only — no CSV)
Severity
Critical (unpaid) / High (past due)
customer
cus_Du…60
status
past_due
latest_invoice
open · $0 collected
at_risk
$29.00/period

That’s all ten

Run your audit. See where you’re leaking.

Run my auditNo CSV needed to start