FFastero
Back to blog

Blog article

How to Automate Won-but-Unpaid Deal Detection in HubSpot

A deal hits "Closed Won" in HubSpot. Nobody sets up billing. Two months later you discover you're owed $15k that was never invoiced. Here's how to build automated detection so this never happens silently again.

Fastero Dev TeamFastero Dev Team
2026-07-26
hubspotstriperevenue-leaksautomationrevopsworkflows
How to Automate Won-but-Unpaid Deal Detection in HubSpot

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.