The worst part about won-but-unpaid deals isn't the revenue you lose. It's how long they stay invisible. A deal moves to "Closed Won" and the CRM celebrates — the pipeline report looks great, the forecast hits target, the AE gets credit. Nobody checks whether money actually arrived until someone runs a quarterly reconciliation (if they even do that) and finds a $15,000 deal that was closed four months ago with no corresponding invoice in Stripe.
I've seen this at companies of every size. A 10-person startup where the founder closes deals and forgets to send the invoice because they're also the engineer. A 200-person company where the handoff from sales to billing goes through three people and a Notion page that nobody reads. A Series C company where custom enterprise deals require manual subscription setup that falls into a gap between RevOps and Finance.
The pattern is always the same: CRM says "won," billing system says "nothing here," and the gap stays invisible until someone manually cross-checks. Here's how to make it impossible to miss.
The three-layer detection approach
You need multiple layers because no single detection method catches everything. A deal might not have a matching Stripe customer because the email differs. Or the Stripe subscription exists but was created under a different amount. Or the payment was made but via invoice rather than subscription. Each layer catches what the others miss.
Layer 1: HubSpot workflow (no code, catches 70%)
This is the fastest to set up and catches the majority of cases. Create a HubSpot workflow triggered when:
Enrollment trigger: Deal stage equals "Closed Won"
Delay: Wait 14 days (or whatever your normal billing setup window is)
If/then branch: Check if the custom property stripe_subscription_status equals "active"
- Yes branch: Do nothing. The subscription was created.
- No branch: Send internal notification email to RevOps/Finance. Create a task assigned to the deal owner: "Deal closed 14 days ago — no active Stripe subscription detected. Please verify billing was set up."
This works if you already have Stripe data syncing to HubSpot (via native integration, Syncsmart, or a custom webhook like the one in our Python sync tutorial). If you don't have that sync running, this workflow has nothing to check against — skip to Layer 2.
Variant for custom properties: If you track stripe_customer_id on the contact, you can branch on whether that property is known (any value) vs. unknown. Less precise than checking subscription status, but works without a full data sync.
Layer 2: Scheduled SQL reconciliation (catches 90%)
This is the real workhorse. A SQL query that joins HubSpot deals against Stripe subscriptions and finds mismatches. You need both datasets in a queryable location — typically a data warehouse (BigQuery, Snowflake, Postgres) that receives syncs from both systems via Fivetran, Airbyte, or custom ETL.
-- Won-but-unpaid deals: closed in HubSpot, no matching Stripe subscription
WITH closed_won_deals AS (
SELECT
d.deal_id,
d.deal_name,
d.amount,
d.close_date,
d.deal_owner_email,
c.email AS contact_email,
c.company_name
FROM hubspot_deals d
JOIN hubspot_deal_contacts dc ON d.deal_id = dc.deal_id
JOIN hubspot_contacts c ON dc.contact_id = c.contact_id
WHERE d.deal_stage = 'closedwon'
AND d.close_date >= CURRENT_DATE - INTERVAL '90 days'
),
stripe_active AS (
SELECT
s.customer_id,
sc.email AS stripe_email,
s.status,
s.created,
s.current_period_start
FROM stripe_subscriptions s
JOIN stripe_customers sc ON s.customer_id = sc.id
WHERE s.status IN ('active', 'trialing', 'past_due')
)
SELECT
cw.deal_id,
cw.deal_name,
cw.amount,
cw.close_date,
cw.deal_owner_email,
cw.contact_email,
cw.company_name,
CURRENT_DATE - cw.close_date AS days_since_close
FROM closed_won_deals cw
LEFT JOIN stripe_active sa
ON LOWER(cw.contact_email) = LOWER(sa.stripe_email)
AND sa.created >= cw.close_date - INTERVAL '7 days'
WHERE sa.customer_id IS NULL
ORDER BY cw.amount DESC;Run this weekly. Email the results to RevOps. Or better — put it in a dashboard that the team checks every Monday.
The email-matching limitation: This query joins on email, which misses cases where the Stripe customer uses a different email than the HubSpot contact. To handle this, add a fallback join on company domain:
-- Extended matching: try email first, then domain
LEFT JOIN stripe_active sa
ON (
LOWER(cw.contact_email) = LOWER(sa.stripe_email)
OR SPLIT_PART(LOWER(cw.contact_email), '@', 2) =
SPLIT_PART(LOWER(sa.stripe_email), '@', 2)
)
AND sa.created >= cw.close_date - INTERVAL '7 days'Domain matching introduces false positives (two different contacts at the same company), but for detection purposes, false positives are fine — you're flagging for human review, not auto-canceling deals.
Layer 3: Real-time event monitoring (catches 95%+)
The first two layers run on schedules. A deal could close on Monday, and if your reconciliation runs Friday, you've lost four days of follow-up time. For high-ACV deals, you want immediate detection.
This requires an event-driven system that watches for deal stage changes in real time and cross-checks Stripe within minutes:
# monitor_deal_stage_changes.py
import os
import time
import stripe
from hubspot import HubSpot
from dotenv import load_dotenv
load_dotenv()
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
hubspot = HubSpot(access_token=os.environ["HUBSPOT_ACCESS_TOKEN"])
def check_deal_has_stripe_subscription(deal):
"""Check if a closed-won deal has a matching Stripe subscription."""
# Get associated contacts
associations = hubspot.crm.deals.associations_api.get_all(
deal_id=deal.id, to_object_type="contacts"
)
for assoc in associations.results:
contact = hubspot.crm.contacts.basic_api.get_by_id(
contact_id=assoc.id,
properties=["email", "stripe_customer_id"],
)
email = contact.properties.get("email")
if not email:
continue
# Search Stripe for this email
customers = stripe.Customer.list(email=email, limit=1)
if not customers.data:
continue
# Check for active subscriptions
subscriptions = stripe.Subscription.list(
customer=customers.data[0].id,
status="active",
limit=1,
)
if subscriptions.data:
return True
return False
def alert_won_but_unpaid(deal, days_elapsed):
"""Send alert for won-but-unpaid deal."""
# Your alerting logic: Slack webhook, email, PagerDuty, etc.
import requests
slack_webhook = os.environ.get("SLACK_REVOPS_WEBHOOK")
if slack_webhook:
requests.post(slack_webhook, json={
"text": (
f"🚨 Won-but-unpaid deal detected\n"
f"*{deal.properties['dealname']}* — "
f"${int(float(deal.properties.get('amount', 0))):,}\n"
f"Closed {days_elapsed} days ago, "
f"no Stripe subscription found.\n"
f"Owner: {deal.properties.get('hubspot_owner_id', 'Unknown')}"
),
})Choosing the right detection window
How long should you wait before flagging a deal as "won but unpaid"? Too short and you'll drown in false alarms (procurement takes time). Too long and you've lost weeks of follow-up opportunity.
| Business type | Suggested window | Why |
|---|---|---|
| Self-serve SaaS | 3 days | Payment happens at signup — any lag is a bug |
| SMB sales-assisted | 7-14 days | Normal onboarding includes billing setup |
| Mid-market | 14-21 days | Procurement, PO numbers, vendor setup |
| Enterprise | 30-45 days | Legal, procurement, MSA, PO, NET30/60 |
Set your initial window conservatively (longer), then tighten it as you learn your actual time-to-first-payment distribution. If 95% of deals create a Stripe subscription within 10 days, flag anything past 14 as potentially stuck.
What to do when you find one
Detection is only half the problem. The other half is a clear escalation process:
Day 0 (deal closes): Timer starts. No action needed.
Day N (detection window expires): Automated alert fires. A task is created in HubSpot assigned to the deal owner: "Billing setup not detected — please confirm subscription was created in Stripe."
Day N+3: If still unresolved, escalate to RevOps/Finance. The deal owner might be on PTO, might have left the company, or might be ignoring the task.
Day N+7: Flag for management review. At this point it's either a process failure that needs a conversation, or a deal that was prematurely closed and needs to be reverted.
Day N+14: If nobody has resolved it, the deal should be moved to a "Billing Pending" stage or equivalent. It should not count in revenue reports until money actually arrives.
The broader pattern
Won-but-unpaid is the most common revenue leak, but it's one of several. Once you have detection running for this pattern, extend it:
- Subscription downgrades not reflected in CRM: Stripe amount drops but HubSpot deal value stays the same
- Failed payment cascades: A payment fails, retries exhaust, subscription cancels — CRM still shows "active customer"
- Trial expirations with no conversion: Trial ends in Stripe, no paid subscription follows, HubSpot deal sits in "Evaluating" forever
Each of these is the same structural problem: two systems that should agree but don't, with nobody watching the gap between them.
If maintaining multiple detection scripts, scheduled queries, and alerting pipelines sounds like a lot — it is. That's the full-time job this problem creates when you solve it with custom code. Fastero's revenue leak detection runs these cross-source joins continuously without you building or maintaining the plumbing. But even if you build it yourself, the important thing is that you build something — because the alternative is discovering these leaks by accident, months after the money was already gone.
Try Fastero free — connect your CRM and billing data, get live revenue dashboards, and set up alerts that catch leaks before your next board meeting. No credit card required.
Last updated: July 2026.

