FFastero
Back to blog

Blog article

How to Reconcile Stripe Revenue with Your CRM

Your CRM says you closed $120k last quarter. Stripe says you collected $97k. The gap isn't a bug — it's timing differences, identity mismatches, and process failures. Here's how to build reconciliation that actually works.

Fastero Dev TeamFastero Dev Team
2026-07-19
stripecrmrevenue-reconciliationrevopsfinance
How to Reconcile Stripe Revenue with Your CRM

Your CRM says you closed $120,000 in new business last quarter. Stripe says you collected $97,000. Your CEO asks which number is real. The honest answer is both — and neither, depending on what you're measuring.

This gap between CRM-recorded revenue and billing-collected revenue exists at virtually every B2B SaaS company. According to Gartner's revenue operations research, organizations that don't actively reconcile these systems experience 5-10% discrepancies that compound over time. The number in the CRM is what sales committed. The number in Stripe is what the bank received. The delta isn't a rounding error — it's a mix of timing differences, structural mismatches, and genuine process failures that only become visible when you force the two systems to agree.

Where the gap comes from

The instinct is to assume someone made a mistake. Usually nobody did — the systems are measuring different things on different timelines, and several structural factors guarantee they'll disagree.

Timing differences. A deal closes on March 28. The customer doesn't enter their payment method until April 3. Stripe processes the first invoice on April 5. The CRM credits Q1. Stripe credits Q2. Neither is wrong. Both are telling you a true thing from their own perspective. Multiply this by dozens of deals closing near quarter boundaries and you get a systematic, persistent gap that has nothing to do with errors.

Partial payments and ramp deals. The deal in HubSpot or Salesforce says $36,000/year. But the customer negotiated a ramp: $2,000/month for Q1, $3,000/month for Q2-Q4. The CRM records the full annual value. Stripe has collected $6,000 through Q1. Are you $30,000 short? No — you're on track. But the reconciliation shows a massive discrepancy unless you account for ramp schedules.

Mid-cycle plan changes. A customer upgrades from $500/month to $800/month mid-quarter. Stripe prorates: the first invoice at the new rate is $650, not $800. The CRM deal gets updated to $800. The per-period amounts no longer match, even though everything is correct.

Refunds and credits. A customer gets a one-month credit for a service outage. Stripe records a credit note. The CRM deal amount doesn't change. Over a quarter, accumulated credits create a gap visible only in Stripe's net revenue.

Multi-year contracts with annual billing. The CRM records a 3-year, $108,000 deal. Stripe bills $36,000 annually. In year 1, the CRM shows $108k closed-won and Stripe shows $36k collected. This is a TCV-vs-ACV-vs-collected distinction, but if nobody defines which number the company tracks, every meeting becomes an argument about which spreadsheet is right.

Failed payments and involuntary churn. The Stripe subscription exists, but the payment method expired. Stripe retries (up to 4 attempts over ~3 weeks by default). During that retry window, the CRM still shows an active customer. If retries fail and the subscription cancels, the CRM doesn't know unless you've built an integration.

Building reconciliation from scratch

Reconciliation is a matching problem. You need to answer: for every deal in the CRM, does a corresponding subscription exist in Stripe, and does the collected amount match the expected amount?

Step 1: Resolve customer identities

Before you can match deal amounts to subscription amounts, you need to know which CRM contact maps to which Stripe customer. This is the same identity resolution challenge described in depth in our revenue leak detection guide, but here's the practical summary.

The most reliable approach is a three-tier match:

-- Identity resolution: match CRM contacts to Stripe customers
-- Tier 1: exact email match
SELECT c.id AS crm_contact_id, sc.id AS stripe_customer_id, 'email_exact' AS match_type
FROM crm_contacts c
JOIN stripe_customers sc ON LOWER(c.email) = LOWER(sc.email)
 
UNION ALL
 
-- Tier 2: domain match (when emails differ but company is the same)
SELECT c.id, sc.id, 'domain_match'
FROM crm_contacts c
JOIN stripe_customers sc
    ON SPLIT_PART(LOWER(c.email), '@', 2) = SPLIT_PART(LOWER(sc.email), '@', 2)
WHERE NOT EXISTS (
    -- exclude already matched by email
    SELECT 1 FROM crm_contacts c2
    JOIN stripe_customers sc2 ON LOWER(c2.email) = LOWER(sc2.email)
    WHERE c2.id = c.id
)
 
UNION ALL
 
-- Tier 3: manual mapping table for known exceptions
SELECT m.crm_contact_id, m.stripe_customer_id, 'manual_mapping'
FROM identity_mappings m;

Tier 1 catches ~70% of matches in a typical B2B SaaS. Tier 2 adds another 15-20%. Tier 3 catches the rest — companies that use different domains, acquisitions that changed company names, and edge cases you'll discover the first time you run the reconciliation.

Step 2: Match deal amounts to subscription revenue

With identities resolved, compare what the CRM says you should have collected against what Stripe actually collected:

-- Revenue reconciliation: CRM expected vs Stripe collected
WITH identity AS (
    -- (use the identity resolution query above)
    SELECT crm_contact_id, stripe_customer_id FROM resolved_identities
),
crm_revenue AS (
    SELECT
        i.stripe_customer_id,
        d.dealname,
        d.amount AS crm_deal_amount,
        d.closedate,
        -- Normalize to monthly for comparison
        CASE
            WHEN d.billing_frequency = 'annual' THEN d.amount / 12.0
            WHEN d.billing_frequency = 'quarterly' THEN d.amount / 3.0
            ELSE d.amount
        END AS expected_monthly
    FROM crm_deals d
    JOIN crm_deal_contacts dc ON d.id = dc.deal_id
    JOIN identity i ON dc.contact_id = i.crm_contact_id
    WHERE d.dealstage = 'closedwon'
),
stripe_revenue AS (
    SELECT
        s.customer_id AS stripe_customer_id,
        s.plan_amount / 100.0 AS actual_monthly,  -- Stripe stores in cents
        s.status
    FROM stripe_subscriptions s
    WHERE s.status IN ('active', 'trialing', 'past_due')
)
SELECT
    cr.dealname,
    cr.crm_deal_amount,
    cr.expected_monthly,
    COALESCE(sr.actual_monthly, 0) AS actual_monthly,
    cr.expected_monthly - COALESCE(sr.actual_monthly, 0) AS monthly_discrepancy,
    CASE
        WHEN sr.stripe_customer_id IS NULL THEN 'NO_SUBSCRIPTION'
        WHEN ABS(cr.expected_monthly - sr.actual_monthly) < 1.0 THEN 'MATCHED'
        WHEN cr.expected_monthly > sr.actual_monthly THEN 'UNDERPAYING'
        ELSE 'OVERPAYING'
    END AS reconciliation_status
FROM crm_revenue cr
LEFT JOIN stripe_revenue sr ON cr.stripe_customer_id = sr.stripe_customer_id
ORDER BY ABS(cr.expected_monthly - COALESCE(sr.actual_monthly, 0)) DESC;

A few implementation notes:

Normalize billing frequency before comparing. A $12,000 annual deal is $1,000/month. A Stripe subscription at $1,000/month is a match, but only if you normalize first. The CRM often stores the total contract value, while Stripe stores the per-period amount.

Stripe amounts are in cents. plan_amount in Stripe's data is 10000 for a $100.00 subscription. Divide by 100 or your reconciliation will show every customer as 100x underpaying.

Use a tolerance threshold. Don't flag a $0.37 discrepancy caused by prorating. A threshold of $1.00 or 1% (whichever is greater) eliminates noise without hiding real issues.

Handle multi-subscription customers. Some customers have multiple Stripe subscriptions (e.g., separate subscriptions for different products or teams). Sum subscription amounts per customer before comparing.

Step 3: Categorize and prioritize discrepancies

Not all discrepancies are equal. The reconciliation output should categorize each mismatch:

Status Meaning Action
NO_SUBSCRIPTION CRM deal exists, no Stripe subscription found Highest priority — this is a won-but-unpaid leak
UNDERPAYING Stripe amount < CRM amount by >$1 Check for ramp deals, mid-cycle downgrades, or billing errors
OVERPAYING Stripe amount > CRM amount Usually a mid-cycle upgrade the CRM hasn't recorded — update the deal
MATCHED Within tolerance No action needed

Sort by discrepancy amount descending. A $50/month mismatch on 40 customers is $24,000/year of unexplained revenue delta. Start with the largest discrepancies.

Three reconciliation approaches by company stage

Under 50 customers: monthly spreadsheet. Export HubSpot deals and Stripe subscriptions. Match manually in Google Sheets. This takes 1-2 hours per month and it works. The email-matching problems are manageable at this scale because you probably know your customers by name. Do this until the manual work takes more than half a day.

50-500 customers: SQL + cron. Land HubSpot and Stripe data in a warehouse using Fivetran (free tier covers both connectors) or Airbyte (open source). Write the reconciliation queries above. Schedule weekly via cron, dbt, or a script that emails results. The identity resolution table is the main maintenance burden — expect 1-2 new manual mappings per month.

This works well if someone owns it. The failure mode isn't technical — it's organizational. The person who wrote the query leaves, nobody understands the identity mapping table, and the weekly email gets ignored.

500+ customers or no warehouse team: automated platform. At scale, identity resolution grows faster than linear, timing-window issues multiply, and the discrepancy categories (ramp deals, multi-year contracts, credits, refunds) outgrow a single SQL query.

Fastero connects directly to Stripe and HubSpot (or Salesforce), runs identity resolution automatically across all three match tiers, and produces a weekly reconciliation digest delivered to Slack or email. It categorizes discrepancies by type (won-but-unpaid, underpaying, overpaying, failed payment, silent churn) and tracks resolution over time. The trade-off is platform dependency in exchange for not maintaining the identity mapping and query logic yourself.

Other tools in this space: Baremetrics and ChartMogul provide Stripe analytics but don't join to your CRM. Clari and Gong are revenue intelligence platforms focused on forecasts and deal inspection within the CRM — they don't reconcile against billing. The CRM-to-billing reconciliation gap is surprisingly underserved.

Making reconciliation stick

The reconciliation query is the easy part. The hard part is making it a process that survives longer than the week you set it up.

Assign ownership. One person (RevOps lead, finance ops, whoever) owns the reconciliation output. Not "the team" — one name. If nobody is named, nobody acts on the discrepancies.

Set resolution SLAs. NO_SUBSCRIPTION discrepancies get a 48-hour SLA. UNDERPAYING discrepancies get a 1-week SLA to investigate. OVERPAYING discrepancies get a 2-week SLA (these are usually benign but the CRM should be updated for forecast accuracy).

Track the trend, not just the snapshot. The total reconciliation gap as a percentage of CRM revenue should trend down over time. If it stays flat, your process has a structural hole that individual deal fixes won't close.

Feed findings back into the sales process. If 30% of your won-but-unpaid deals trace to the same AE, the problem isn't billing ops — it's the handoff. If most timing discrepancies cluster around quarter-end, add a billing-setup gate before deals can move to closed-won.

The goal isn't perfect reconciliation. There will always be a timing delta between when a deal closes and when Stripe processes the first payment. The goal is that every discrepancy above your threshold has an explanation, and no deal sits in "closed-won, never paid" for more than two weeks without someone knowing.


Want to see where your CRM revenue and Stripe revenue disagree? Start a free trial — connect both systems and get your first reconciliation report automatically.

Related: How to detect revenue leaks between CRM and billing | Revenue monitoring