Stripe's basil API broke my grace-period logic, and CE 3.0 turned out to be a routing problem
When we pinned our webhook endpoint to 2025-03-31.basil, entitlement checks started failing quietly. Nothing threw an error. current_period_end on the top-level Subscription object was undefined, and in JavaScript undefined < Date.now() evaluates to false. Every subscription looked like it was still inside its paid period, so unpaid accounts kept burning API credits without ever being revoked.
I'll cover the entitlement trap first, then the Visa 10.4 dispute routing problem that showed up right behind it.
1. The silent undefined in 2025-03-31.basil
Most older Stripe webhook handlers check whether a past_due subscription is still within a grace window by reading the top-level period timestamp:
// Legacy pattern — silently evaluates to false on 2025-03-31.basil const isExpired = sub.current_period_end * 1000 + GRACE_MS < Date.now();
In 2025-03-31.basil, billing period fields moved off the subscription root and onto items.data[]. That sounds like a one-line change until you hit the edge cases in real B2B accounts:
- Multi-item subscriptions & pagination: Base seats and metered add-ons can carry different billing intervals, and large enterprise subscriptions can paginate
items.data(has_more: true). If you only inspectitems.data[0]or don't fail closed when item periods are missing, you either lock out an active seat tier or leave expired usage open. - Customer-level vs. subscription-level state: A single B2B customer often holds multiple subscriptions (for example, a canceled legacy plan alongside an active annual plan, or a duplicate checkout where
emailmatches twocus_*records). Acting on a singlecustomer.subscription.deletedwebhook without reconciling the customer's full active subscription list (has_moreaware) cuts off paying teams. - Out-of-order webhook delivery & grace policy: During dunning retries,
invoice.payment_failedandcustomer.subscription.updatedevents frequently arrive out of order. A production entitlement guard combines a configurable0–14 daymerchant dunning grace policy (dunningGracePeriodDays) with a 72-hour webhook deduplication window and transition-timestamp ordering so a delayed retry webhook never overwrites a freshly paid renewal.
2. Why Visa 10.4 (CE 3.0) is a two-tier routing problem
When a card-absent fraud dispute (fraudulent / Visa 10.4) lands, most teams either paste generic logs manually or pay a 25%–30% contingency fee to a dispute agency.
Under Visa Compelling Evidence 3.0 (CE 3.0), qualifying requires at least two prior succeeded, undisputed, unrefunded transactions settled 120 to 365 days before the disputed charge that share at least two consistent core identifiers (user_id, email, ip_subnet, device_id, shipping_address) across both prior charges and the disputed charge, including at least one network or device identifier (ip_subnet or device_id).
That means routing between Tier 1 and Tier 2 depends on whether qualifying historical transactions exist, not solely on account age:
- Tier 1 (First-Party Usage Log Defense): Applies when an account is under 120 days old, or when a 150-day-old account has only one prior charge or lacks
ip_subnet/device_idoverlap across prior charges. Because CE 3.0 criteria are not met, the strongest defense is a structured 90-day first-party usage log (authenticated logins, API calls, and feature activity tied to the customer's IP and user ID). - Tier 2 (CE 3.0 Liability-Shift Eligibility): Applies when at least two undisputed, unrefunded historical charges settled 120–365 days before the disputed charge share at least two consistent core identifiers—including
ip_subnetordevice_id—with the disputed charge.
| Scenario | Legacy Webhook / DIY Behavior | Failure Mode | RevClamp Pipeline Action |
|---|---|---|---|
2025-03-31.basil Multi-Item Drift |
Reads sub.current_period_end (undefined) |
Infinite unpaid API/LLM token bleed | Aggregates items.data[] periods (has_more aware, fail-closed) |
| Multi-Subscription Cancellation | Revokes access on first subscription.deleted |
False lockout of active annual plan | Reconciles full customer subscription set before revoking |
| Out-of-Order Dunning Webhook | Overwrites state on late invoice.payment_failed |
Paid renewal reverted to past_due |
Enforces 72h event deduplication, transition ordering & 0–14d grace |
Visa 10.4 (<2 Qualifying Priors) |
Submits incomplete CE 3.0 claim or generic text | Automatic issuer rejection | Routes to Tier 1 90-day first-party usage log (submit: false) |
Visa 10.4 (≥2 Priors 120d–365d) |
Overwrites manual support notes on update | Wipes finance context & truncates UTF-16 code units | Summarizes CE 3.0 priors + usage log non-destructively (<= 18,000 chars) |
3. Why dispute automation should stage (submit: false), never auto-submit
In the Stripe Disputes API, submitting evidence (submit: true) is irreversible—you get one submission to the card network. Even worse, a naive stripe.disputes.update() call overwrites uncategorized_text and access_activity_log, wiping out any context your support or finance team already typed into the Stripe Dashboard.
A safe pipeline evaluates CE 3.0 eligibility, summarizes the matched prior transaction IDs (120–365 days old), shared identifiers, and 90-day first-party usage log inside evidence[access_activity_log] with submit: false, and merges non-destructively around existing delimiter blocks within the 18,000 UTF-16 code-unit ceiling. Your team reviews the staged draft in the Stripe Dashboard and clicks Submit when ready.