Somewhere in your HubSpot pipeline right now, there's a deal marked "closed-won" that never turned into a Stripe subscription. The contract was signed. The AE celebrated. The CRM shows revenue. But your bank account doesn't.
Clari's research puts typical B2B revenue leakage at 3-8% of total revenue. At $1M ARR, that's $30,000-80,000 per year quietly vanishing between your CRM and your billing system. The leak isn't in either system — HubSpot is telling the truth about what sales recorded, and Stripe is telling the truth about what got collected. The gap lives between them, in the handoff nobody is watching.
This post is about one specific, high-impact leak pattern: deals marked closed-won in HubSpot that have no corresponding active subscription in Stripe within a reasonable window. We'll cover why it happens, how to detect it at different levels of sophistication, and how to make sure it stops happening silently.
Why won-but-unpaid deals happen
This almost never involves dishonesty or data corruption. It's a process gap — and once you know the patterns, they're predictable.
The sales-to-CS handoff dropped. The AE closes the deal, moves the stage to closed-won, and turns their attention to the next opportunity. Customer Success is supposed to pick up onboarding, which includes getting the customer set up in Stripe with the correct plan and billing details. But the handoff is a Slack message or an internal note on the deal record, not an automated workflow. If CS is swamped, if the onboarding task gets lost in a queue, or if the CSM assigned to the account is on PTO — the deal sits in closed-won limbo. Nobody notices because HubSpot doesn't know Stripe exists.
Procurement bottleneck on the customer side. The buyer said yes. Their procurement team needs 3-6 weeks to process a vendor. Legal wants to redline the MSA. Finance needs a PO number before they can set up payment. The deal is legitimately closed-won — the decision was made — but the payment infrastructure on the customer's side moves slowly. Two months later, the deal is still won-but-unpaid, and nobody on your side is tracking the lag because the CRM says "won" and that's the end of the sales pipeline.
Payment setup failed silently. Someone tried to create the Stripe subscription. The card was declined, or the invoice went to the wrong email. Stripe shows a failed attempt or a draft invoice, but that failure notification went to a shared billing inbox nobody monitors daily.
Multi-product or custom deals. The deal was for a package that doesn't map to a single Stripe plan — maybe it includes an implementation fee plus a recurring subscription, or a custom enterprise price that doesn't exist as a Stripe product yet. Someone needs to manually create the subscription, and that "someone" didn't get the memo.
Premature close. The AE moved the deal to closed-won to hit a quota deadline. The contract never finalized, but the deal stage was never reverted. HubSpot records revenue that was never real.
The manual approach: VLOOKUP in a spreadsheet
If you have fewer than 50 active customers, the spreadsheet approach genuinely works.
Step 1: Export closed-won deals from HubSpot. Filter to "Deal stage = Closed Won" and "Close date = last 90 days." Export to CSV with deal name, amount, close date, and associated contact email.
Step 2: Export active subscriptions from Stripe. Billing → Subscriptions → Export. You get customer email, status, plan, and amount.
Step 3: VLOOKUP. Match HubSpot contact email against Stripe customer email. Flag any deal where VLOOKUP returns no match.
This takes about 30 minutes the first time. It finds the obvious cases. And it breaks in several ways that matter:
Emails don't match. The HubSpot contact is jane@acme.com (the buyer). The Stripe customer is billing@acme.com (finance's shared inbox). VLOOKUP finds no match. You either miss the leak or flag a false positive that erodes trust in the process.
Timing windows. You exported HubSpot at 10am and Stripe at 10:15am. A subscription was created at 10:10am. VLOOKUP says it's missing. You investigate a non-issue.
Nobody remembers to do it. You run this once, find two issues, fix them, feel good. Then you don't do it again for three months, during which four more deals leak.
It doesn't scale. At 200+ customers, the email-matching problem compounds (more aliases, more billing contacts, more edge cases) and the manual effort stops being worthwhile.
The SQL approach: if your data is in a warehouse
If you're syncing HubSpot and Stripe data into a warehouse (Postgres, BigQuery, Snowflake, Redshift) using a tool like Fivetran, Airbyte, or HubSpot's native data sync, you can write the detection query directly.
-- Won-but-unpaid: HubSpot deals closed >14 days ago with no Stripe subscription
SELECT
d.dealname,
d.amount,
d.closedate,
c.email AS contact_email,
d.dealstage,
CURRENT_DATE - d.closedate::date AS days_since_close
FROM hubspot_deals d
JOIN hubspot_contacts c
ON d.associated_contact_id = c.id
LEFT JOIN stripe_subscriptions s
ON LOWER(c.email) = LOWER(s.customer_email)
AND s.status IN ('active', 'trialing', 'past_due')
WHERE d.dealstage = 'closedwon'
AND d.closedate < CURRENT_TIMESTAMP - INTERVAL '14 days'
AND s.id IS NULL
ORDER BY d.amount DESC;A few things to note about this query:
The 14-day window is deliberate. A deal closed yesterday that doesn't have a Stripe subscription yet isn't necessarily a leak — onboarding takes time. Fourteen days is a reasonable threshold for most B2B SaaS; adjust based on your typical sales-to-billing cycle. If your enterprise deals routinely take 30+ days to set up billing, widen the window.
LOWER() on email matching helps but doesn't solve the identity problem. Jane@Acme.com and jane@acme.com will match. jane@acme.com and billing@acme.com won't. More on this below.
The join path through hubspot_contacts matters. Deals in HubSpot are associated with contacts through an association table. The exact schema depends on your ETL tool — Fivetran uses hubspot_deal_contact, Airbyte structures it differently. Check your warehouse schema.
Check for multiple contact associations. A single deal can be associated with multiple contacts. If any of those contacts has a matching Stripe subscription, the deal isn't leaked. A more robust version uses EXISTS with a subquery across all associated contacts:
SELECT d.dealname, d.amount, d.closedate
FROM hubspot_deals d
WHERE d.dealstage = 'closedwon'
AND d.closedate < CURRENT_TIMESTAMP - INTERVAL '14 days'
AND NOT EXISTS (
SELECT 1
FROM hubspot_deal_contacts dc
JOIN hubspot_contacts c ON dc.contact_id = c.id
JOIN stripe_subscriptions s
ON LOWER(c.email) = LOWER(s.customer_email)
AND s.status IN ('active', 'trialing', 'past_due')
WHERE dc.deal_id = d.id
);The identity resolution problem
This is the part that makes automated detection genuinely hard, and it's the reason most teams give up after the first attempt.
Your HubSpot contact has an email. Your Stripe customer has an email. Sometimes they match. Often they don't:
- Role-based vs personal emails. HubSpot has
jane@acme.com(the buyer). Stripe hasap@acme.com(finance's shared inbox). - Domain aliases. The company was
acme.iowhen the deal was signed andacme.comby the time billing was set up. - Plus-addressing.
jane+billing@acme.comwon't matchjane@acme.comin a naive comparison. - Multiple contacts, one customer. The deal is associated with the VP who signed off, but the Stripe subscription was created by the ops manager.
The reliable solution is matching on company domain rather than individual email, then manually reviewing edge cases:
-- Domain-based matching: more resilient than email-exact
SELECT d.dealname, d.amount, d.closedate
FROM hubspot_deals d
JOIN hubspot_contacts c ON d.associated_contact_id = c.id
LEFT JOIN stripe_customers sc
ON SPLIT_PART(LOWER(c.email), '@', 2) = SPLIT_PART(LOWER(sc.email), '@', 2)
LEFT JOIN stripe_subscriptions s
ON sc.id = s.customer_id
AND s.status IN ('active', 'trialing', 'past_due')
WHERE d.dealstage = 'closedwon'
AND d.closedate < CURRENT_TIMESTAMP - INTERVAL '14 days'
AND s.id IS NULL
ORDER BY d.amount DESC;This catches the jane@acme.com / billing@acme.com case. It still fails on domain mismatches and companies using multiple domains. At scale, you end up maintaining a mapping table of known domain aliases, which is unglamorous but necessary work.
Turning detection into a workflow
Finding won-but-unpaid deals once is useful. Finding them automatically, every week, is what actually prevents revenue from leaking.
The minimum viable workflow:
- Run the detection query on a schedule. Weekly is sufficient for most teams. Daily if your deal volume is high.
- Route results to the right person. A Slack message to the RevOps channel with the deal name, amount, days since close, and contact info. Not a dashboard — a notification, because dashboards require someone to remember to look.
- Set an SLA for resolution. Every flagged deal gets a 48-hour SLA to either (a) confirm billing is in progress or (b) escalate as a genuine leak.
- Track resolution. Did the subscription get created? Did the deal get moved back to a negotiation stage? Did the customer ghost? Each outcome informs whether your process needs a fix or just a nudge.
If you're building this yourself, you need a scheduler (cron, Airflow, GitHub Actions), a notification integration (Slack webhook or email), and someone who owns the process. The query is the easy part. The workflow is where most teams stall.
Fastero's revenue leak detection automates this pattern: connect HubSpot and Stripe, and it runs won-but-unpaid checks on a schedule, delivering results as a Slack or email digest with identity resolution handled automatically.
What to do with what you find
When you flag a won-but-unpaid deal, the response depends on how long it's been:
14-30 days post-close: Likely a process delay. Ping the CSM or billing ops person. Most of these resolve with a nudge — the subscription just needs to be created.
30-60 days post-close: Now a retention risk disguised as a billing gap. The customer committed but was never onboarded. Reach out with an onboarding check-in, not a billing request. The longer the gap, the more likely the champion has moved on.
60+ days post-close: Treat this as a potential dead deal. Update the CRM to reflect reality and exclude the amount from recognized revenue. If the customer is still reachable, treat it as re-engagement, not collections.
The most valuable outcome isn't recovering any individual deal — it's revealing systematic handoff failures. If 80% of won-but-unpaid deals trace to the same onboarding bottleneck, fix the bottleneck once and the entire category of leaks stops.
Want to find out how many won-but-unpaid deals are sitting in your HubSpot right now? Start a free trial — connect HubSpot and Stripe, get your first leak report in minutes.

