Incident explainer
Beginner-friendly
Billing / Stripe migration

“Metered prices must be backed by meters”

A from-scratch explanation of the Stripe billing incident of 2026-07-09 — every term, every tool, customer & internal impact, a system diagram, and all possible solutions. Sourced from the billing chat thread, FINCBO-789 / FINCBO-667, and the internal Billing handover wiki.

On this page
  1. 30-second version
  2. Glossary (every term)
  3. How the system fits together
  4. Why it breaks
  5. Root causes
  6. Who is impacted
  7. All solutions
  8. Quick reference & tickets

1 · The 30-second version

Cloudflare is moving all “usage-based” billing onto a new, faster Stripe mechanism called “meters.” Stripe turned on a rule: any price that charges by usage must now be attached to a meter.

Some old “usage” prices never got moved to meters. They sit harmlessly — until a customer (or an internal process) tries to change their subscription. Then Stripe rejects the entire change:

Starting with Stripe version 2025-03-31.basil, metered prices must be backed by meters.

Stripe updates a subscription all-or-nothing, so one bad leftover item poisons the whole subscription — the customer can’t upgrade, downgrade, add, or cancel anything. About 10,000 accounts have ≥1 bad item (~200 paying, rest mostly free). There’s no instant fix: cleanup means “reconciling drift,” and a live code bug in SubsAPI keeps re-creating bad items.

Cloudflare = what we believe Stripe = what the customer is actually billed Drift = the two disagree

2 · The cast of characters (plain-English glossary)

Core money / plumbing

StripeStripe — the outside company that actually charges cards, makes invoices, and stores subscriptions. The cash register + ledger. We don’t own it; we send instructions via its API.

StripeSubscription — a customer’s ongoing billing relationship: “pays $X every month/year for these things.”

StripeSubscription Item (SI) — one line inside a subscription. Subscription = folder; each SI = one product. This incident is about bad SIs.

StripePrice / price_id — reusable object: “this product costs this, billed this way.” Every SI points to a price.

StripeInvoice — the bill Stripe generates each period. A broken subscription can double-bill or repeat invoices.

StripeProration — the partial-period math when you change mid-cycle (a credit for unused time, or a top-up charge). Where accidental “full-year credit” mistakes happen.

Stripe2025-03-31.basil — just Stripe’s version label for the behavior that enforces “usage prices must have a meter.” “basil” is Stripe’s codename, like a version number.

Usage-based billing (the heart of it)

Usage-based / “metered” product — you pay by how much you use, not a flat fee (Workers requests, R2 storage, Spectrum data). Opposite of a flat “Pro plan.”

CloudflareUBB (Usage-Based Billing) — our pipeline that measures each customer’s usage and reports the number to Stripe.

StripeLegacy “usage records” API — Stripe’s old way to report usage (attached to one SI). Slow, bloats subscriptions. The broken kind.

StripeMeter — Stripe’s new usage mechanism: a named stream of usage events attached to the customer, not one SI. ~10× faster. Because it’s tied to the customer, duplicate subscriptions cause double-billing.

Metered vs non-metered price — metered = wired to a meter (new, correct). Non-metered / “legacy usage” = still on the old mechanism (broken). The error means Stripe found a non-metered usage price and refused it.

-meter lookup key — the naming tell: a metered price’s lookup key ends in -meter (a few early-migration exceptions). Support’s quick “is this good?” check.

ToolMigration script / “stripe-resync” — the job that converts old non-metered SIs into new metered ones (swaps in a -meter version).

Cloudflare’s internal model

CloudflareOPE (Object Products Element)our own source of truth for what a customer has. A row in subs_object_products_element, one product component per zone/account. OPE = what we believe; Stripe = what they’re billed. They must match.

CloudflareObject Products (OP) — the parent record: the billable object (zone/account) the OPEs hang off.

Cloudflarelicense_source_id — field on each OPE saying how it’s billed. sub_X:si_Y = linked to a real Stripe item. This is the bridge between OPE and Stripe.

CloudflareEntitlements — the switches that actually turn features on/off for the customer, derived from billing state.

CloudflareSubsAPI / subscriptions-api / “The Engine” — the central service with all billing brains: computes changes, talks to Stripe, updates OPE, syncs entitlements. Where the live bug was found.

Cloudflarechanges_calculator / “Calc” — the single code path every change flows through: Gather → Validate → Plan → Sync to Stripe + Save to DB. Huge and fragile (no full regression tests).

Cloudflarebilling-webhooks / “The Pipe” — listens to Stripe events (50–100K/day) and feeds them back into SubsAPI (“invoice paid,” “downgrade took effect”).

Subscription-change behavior

Upgrade — bigger/pricier plan → applied immediately.

Downgrade — smaller/cheaper plan → delayed to period end so the customer keeps what they paid for.

CloudflareSSLC (Scheduled Service Level Change) — records “at period end, drop this product.” Captures the intent to downgrade later. Biggest user of subscription schedules.

StripeSubscription Schedule — “the sub looks like this now, then auto-changes to that on a future date.” Stores the whole future state — so one bad item inside makes the schedule fail with the metering error when it fires.

Dual subscriptions (by design) — each pay-as-you-go customer normally has two Stripe subs: one monthly (usage: Workers, R2, Stream), one yearly (zone plans: Pro/Biz/Ent). Two monthly = a bug (FINCBO-667).

Drift — the underlying disease

Drift — Cloudflare’s records (OPE) and Stripe disagree. The metering error is one symptom of drift.

TypeWhat it isRiskUsual fix
AIn Stripe, not in OPE (orphaned SI)OverchargingRemove it from Stripe
BIn OPE, not in StripeUndercharging / lost revenueAdd to Stripe (free rest of cycle)
C / DFiner-grained mismatchesBothCase-by-case

ToolDrift tool — cfbill subscriptions resync-stripe — makes Stripe match OPE (OPE is truth). Use --skip-usage=false for usage items. Has sharp edges (see solutions).

Orphaned SI — a Stripe item with no matching OPE. Usually harmless for revenue (“a stupid record in the way”) but can be the one bad item freezing the whole subscription.

Tickets, tools & jargon from the thread

FINCBO — Jira project “Fintech Core Billing & Orchestration” (billing eng bugs).

ESCALATION-xxxx — customer-facing support tickets, linked up to the eng ticket.

ToolKibana — log search; used to count the errors (8,000 → 300).

ToolSigma — query tool over Stripe data; found 3,800+ schedules with the bad Spectrum price.

Write gates — internal on/off + % control (feature flag) for how much traffic uses the new metered write path. “Ramped to 100% Tuesday” = errors exploded.

v84 / go-nogo — the migration’s release version; the meeting that OK’d ramping up, accepting ~100 unmigrated items would be “bashed.”

“bash over” — forcefully overwrite an old non-metered price with the metered one, skipping careful migration. Safe here because only ~100 items actually billed last month.

Bad debt / DNU / ban — account states where the drift tool intentionally won’t act.

3 · How the whole system fits together

Two worlds that must stay in sync. A change starts in Cloudflare, gets pushed to Stripe all-or-nothing, and Stripe events flow back through “The Pipe.”

flowchart TB
    USER([Customer clicks upgrade / downgrade / cancel]):::user

    subgraph CF["CLOUDFLARE — what we believe"]
      direction TB
      SUBSAPI["SubsAPI · 'The Engine'"]:::cf
      CALC["changes_calculator 'Calc'
Gather → Validate → Plan → Sync+Save"]:::cf OPE[("OPE — source of truth
subs_object_products_element")]:::cf SSLC["SSLC — delayed downgrades"]:::cf ENT["Entitlements — features on/off"]:::cf UBB["UBB — measures usage"]:::cf end subgraph STRIPE["STRIPE — what the customer is billed"] direction TB SUB["Subscriptions
normally 1 monthly + 1 yearly"]:::st SI["Subscription Items (SIs)
each points to a price_id"]:::st SCHED["Subscription Schedules
future state for downgrades"]:::st METER["Meters (new)
attached to CUSTOMER"]:::st INV["Invoices"]:::st end PIPE["billing-webhooks 'The Pipe'"]:::cf DRIFT["Drift tool
cfbill subscriptions resync-stripe"]:::tool ERR{{"❌ metered prices must be backed by meters"}}:::err USER --> SUBSAPI --> CALC CALC -->|write state| OPE CALC -->|push change ALL-OR-NOTHING| SUB CALC --> SSLC --> SCHED CALC --> ENT SUB --> SI SI -->|usage price must link to| METER UBB --> METER SUB --> INV STRIPE -->|events| PIPE --> SUBSAPI OPE -. license_source_id = sub_X:si_Y .-> SI DRIFT -. reads .-> OPE DRIFT -. fixes .-> SI SI -->|bad = non-metered usage| ERR SCHED -->|holds bad Spectrum price| ERR classDef cf fill:#16233f,stroke:#3a558f,color:#dce6ff; classDef st fill:#241a3f,stroke:#6a53b0,color:#e7ddff; classDef tool fill:#12301f,stroke:#2f7c58,color:#c7f5df; classDef err fill:#3a1417,stroke:#7c3030,color:#ffc0c0; classDef user fill:#3a2a10,stroke:#7c5a20,color:#ffe0b0;
The failure in one sentence: when Calc (or a firing SSLC schedule) tries to update a Stripe subscription that still contains even one non-metered usage SI, Stripe rejects the entire update → the customer is frozen.

4 · Why it breaks (step by step)

sequenceDiagram
    participant C as Customer
    participant E as SubsAPI / Calc
    participant S as Stripe
    C->>E: Change subscription (e.g. cancel Spectrum)
    E->>E: Build plan from OPE
    E->>S: Update subscription (all items at once)
    Note over S: Sees a non-metered usage SI
with no meter S-->>E: ❌ Reject WHOLE update
"metered prices must be backed by meters" E-->>C: Error — no change applied Note over C,S: One bad item freezes upgrade,
downgrade, add AND cancel

5 · Root causes (the five real pathways)

Separate problems, same error message:

1 · Usage drift (orphaned SI)

The migration only converts items it can match to an OPE. No OPE → never converted → leftover non-metered item. The drift tool’s usage flag was off until recently, so this piled up silently. Ex acct 11267554.

2 · SSLC schedules with bad Spectrum price

SSLC created Stripe schedules referencing Spectrum’s old price price_1NVI9FCM3YxTUEmtNLh1dCcR. 3,800+ such schedules still active; they fail when the downgrade date arrives.

3 · Spectrum “migration variant” ~50% drifted

price_1Ofme…: only 3 OPE entries vs 113 Stripe subitems. The non-migration version: 41 OPE vs 1,000+ Stripe (out of ~23K).

4 · Duplicate subscriptions (FINCBO-667)

~215 customers have 2+ monthly subs (should be 1 monthly + 1 yearly). A race condition creates the extra. Meters + duplicate subs = double billing. One account invoiced 14× in 48h.

5 · The SubsAPI “smoking gun” 🔫

A prior code change was incomplete: normal upgrades/downgrades are supposed to auto-“bash” non-metered prices into metered ones, but they don’t do it correctly. So even ordinary changes keep re-introducing bad items. Fixing this was expected to drastically cut the errors. Tracked in FINCBO-789.
Already fixed during the incident: wrong lookup keys on some Image products + a PlanetScale issue — dropped volume from ~8,000 to ~300 in 6 hours.

6 · Who is impacted, and how

Customer impact

Frozen subscriptions — ≥1 bad item blocks all changes (upgrade, downgrade, add, cancel). ~10,000 accounts affected; ~200 paying, rest mostly free.

Blocked cancels (ESCALATION-5104) and blocked upgrades (ESCALATION-5102).

Double / repeated billing (smaller set) — duplicate subs + meters: one account invoiced ~14× in 48h; another stuck at 18–19 “Pro plans.”

Accidental large credits — careless drift-tool use could hand a Pro-annual→ENT customer ~a full year’s credit.

Internal / business impact

Escalation pile-up — needed a central ticket (FINCBO-789 · P2 Critical).

Likely a formal incident next.

SSLC failing today — scheduled downgrades don’t execute.

Revenue / SOX exposure — drift is compliance-tracked (over/under-charging).

Knowledge risk — drift + SSLC expertise concentrated in one person.

No fast fix — only real remedy is slow drift reconciliation, with caveats.

7 · All possible solutions

A · Immediate per-account workarounds (support / on-call)

1. Manually remove the orphaned/bad SI in Stripe.
Best when it’s a Type-A orphan with no revenue. Avoids the drift tool’s side effects (this is what saved the Pro-annual→ENT account from a year’s credit).
2. Remove the Spectrum non-metered item from the schedule (when the metered item is already on the sub). Fixes failing SSLC schedules.
3. Run the drift tool for one account: cfbill subscriptions resync-stripe --skip-usage=false
Sharp edges — read before running:
  • Actions pending upgrades/downgrades immediately instead of on their scheduled date.
  • If OPE has a plan not in Stripe, it creates it → can trigger huge prorations/credits (full-year-credit trap).
  • Won’t run on bad-debt / DNU / banned accounts.
  • Expects one monthly sub; duplicate subs confuse it and can make things messier.

B · Batch remediation (engineering-run)

4. The “hail mary” pipeline: query accounts whose OPEs still use legacy usage prices → drift-tool them clean → re-run meter migration. Split into the two scenarios (orphans vs schedules); side effects above still apply.
5. “Bash over” the ~100 remaining unmigrated SIs with metered versions (no full migration). Financially safe (only ~100 billed last month) and already agreed for the v84 rampup.
6. Sweep the 3,800+ Spectrum schedules referencing price_1NVI9FCM3YxTUEmtNLh1dCcR (Sigma query), plus any other non-metered price IDs still in schedules — using the old→new price-ID migration sheet.

C · Root-cause code fixes

7. Fix the incomplete SubsAPI change (smoking gun). Make Calc correctly bash non-metered→metered on every change so normal flows stop creating bad items. In-progress at thread’s end. Biggest lever.
8. Fix duplicate-sub creation (FINCBO-667 / BILLACCT-282). Fix the race, enhance the drift tool to cancel extra subs, back-fix ~215 existing customers. Stops double billing.
9. Keep the drift tool’s usage flag on + run the daily drift job reliably so usage drift can’t silently pile up again.

D · Structural / preventative (longer term)

10. Make OPE a prepopulated source of truth with a state machine — pre-write OPE pending, drive Stripe from it (instead of “do in Stripe, then update OPE”).
11. Finish SSLC consolidation — move all delayed-downgrade logic out of OPE into SSLC; kills the fragmented phase logic + “null phase 0” bug.
12. Rebuild resync per-account — sync all of an account’s OPEs into Stripe in one pass (enforce “≤2 subscriptions”), not the fragile per-sub Calc approach.

Recommended sequence

1
Stop the bleeding — ship #7 (SubsAPI bash bug).
2
Bulk clean the long tail — #5 (bash ~100) + #6 (Spectrum schedules).
3
Handle live escalations — #1/#2 manual edits; #3 drift tool only with the caveats.
4
Kill double-billing — #8 (FINCBO-667).
5
Prevent recurrence — #9 daily job, then #10–#12 structural.

8 · Quick reference & tickets

“Is this price bad?”

Lookup key ends in -meter → metered → good (few early exceptions).

Usage price with no meter → bad (what Stripe rejects).

Master old→new price-ID list: migration spreadsheet.

Known bad Spectrum IDs: price_1NVI9FCM3YxTUEmtNLh1dCcR (non-metered, 3,800+ schedules), price_1OfmeRCM3YxTUEmtcuJ24Vdj (~50%-drifted migration variant).

Ticket map

FINCBO-789 · P2 this incident — link escalations here.

FINCBO-667 duplicate monthly subs (double-billing).

ESCALATION-5102 upgrade/downgrade error · ESCALATION-5104 can’t cancel.

Background: BILLACCT-282 race→dup subs · BILLACCT-285 idempotency false-success · BILLACCT-287 null price_id_phase_0.