Skip to content
Seamline
← Use cases

Why doesn't your HubSpot ARR match Stripe?

7 min read (+ optional SQL)

By Tim, founder of Seamline · Last updated 6 October 2026

Both numbers are usually right about something. A proper revenue reconciliation agrees on what ARR and MRR mean, then explains every variance, customer by customer.

The board asks for ARR. Sales pulls it from HubSpot. Finance pulls it from Stripe. The numbers are different, both people defend theirs, and the meeting ends with "let's align offline".

This use case turns that into a monthly revenue reconciliation check you can actually run: link each customer across both systems, reconcile ARR against Stripe MRR, and give every difference a reason someone can fix. You can read it in about seven minutes. For anyone who wants to build it, the key steps have SQL tested on Postgres behind a toggle, and a technical appendix lets you run the whole example yourself.

The question

Not "what is our ARR?". That question invites two answers and an argument. This one produces a list where every row explains itself:

CustomerHubSpotStripeDifferenceWhy
Brightpath Learning€60,000€30,000−€30,000Two-year contract value entered as ARR
Fjord Studio€12,000€0−€12,000Cancelled in Stripe, still a customer in HubSpot
Helix Freight—€8,400+€8,400Paying, but not a customer in HubSpot
Kestrel Logistics€18,000€24,000+€6,000Extra seats added in Stripe, not in HubSpot

All companies, people and numbers in this use case are fictional.

Why the numbers differ

They measure different things.

HubSpot ARRStripe MRR × 12
What it isWhat someone entered, usually when the deal closedCalculated from the subscriptions running right now
Updated whenA person or a workflow changes itBilling changes: upgrades, cancellations, discounts
Often includesContract value, list price, one-off feesOnly recurring amounts, normalised to a month
Often missesMid-contract upgrades, downgrades, churnCustomers invoiced outside Stripe

Stripe's own definition is specific: MRR is the monthly-normalised value of all active and past_due subscriptions, excluding taxes, free plans and metered usage. A cancelled or unpaid subscription counts as churn, and whether discounts are subtracted is a setting you choose (Stripe docs). HubSpot has no equivalent rule. Its ARR is whatever your team decided to put in the field, if anyone decided at all.

An off-the-shelf HubSpot Stripe sync doesn't fix this by itself. A standard sync moves records between systems, but it cannot perform revenue reconciliation: it doesn't decide which records belong to the same customer, how MRR translates to ARR, or why the totals differ. Check exactly which objects your setup syncs and how it matches them before relying on it.

How revenue reconciliation works across CRM and billing

 HubSpot ─────┐
              │
 Stripe ──────┤
              ▼
      Customer identity      one canonical ID, linked to every system's records
              │
              ▼
    Business definitions     what counts as ARR, agreed once
              │
              ▼
       Reconciliation        per customer, with a reason for every difference
              │
        ┌─────┴─────┐
        ▼           ▼
     HubSpot      Slack      ARR written back, differences sent to an owner

The important part isn't the connection. It's the layer between the systems. Plenty of tools connect HubSpot and Stripe. The work is making them agree on what a customer is and what ARR means.

Before you can compare amounts, both systems have to agree on who the customer is. That's harder than it sounds, for three reasons specific to billing:

  • One company, several Stripe customers. A German entity, a second team, a card someone added on their own. Their ARR has to be added up before you compare.
  • The payer isn't the company. Invoices go to mosaic.retail@gmail.com or to an outsourced accountant. Domain matching can't link those.
  • Self-serve signups never reach the CRM as customers. They pay every month and are invisible to sales and CS.

So give every business customer one canonical ID, then link each system's records to it. One customer can have several records in the same system:

 CUST_000001  Northwind Analytics
   ├── HubSpot   company hs_101
   ├── Stripe    cus_A1    Northwind Analytics B.V.
   └── Stripe    cus_A1b   Northwind Analytics GmbH

Rules suggest links, a person confirms them, and only confirmed links count. The method is the same as in our renewal-risk use case. Anything that isn't linked shows up as its own row, so it can't silently disappear from the total.

Step 2: Define ARR once

This is the step that ends the argument. Write the definition down, agree on it with finance and sales, and put it in the query. The one used here:

Definition used hereDecide with your team
CountsSubscriptions that are active or past_dueShould past_due count? Stripe says yes until it becomes unpaid
AmountRecurring line items × quantity, normalised to a yearDo one-off setup fees ever count? (Usually not)
DiscountsPermanent discounts subtractedWhat about discounts that expire in three months?
ExcludedTrials, cancelled and unpaid subscriptions, taxUsage-based revenue: separate number, or a trailing average?
CurrencyConverted to EUR at a fixed rateWhich rate, fixed when, and who updates it?

Then decide which system is the source for which number. ARR is a business definition, not a field in a tool, so this is a choice your company makes. A setup that holds up for most subscription businesses:

  • For this setup, Stripe is the source of truth for current, billing-based ARR. It's what you're actually billing.
  • HubSpot owns contract value, in its own field. Two-year deal, €60,000: that's contract value, not ARR.
  • The ARR field in HubSpot is written by one thing only: a sync from billing, never a person. One writer per field.
Show the SQL for step 2

ARR per billing customer, following the definition above:

-- ARR per billing customer, using one written-down definition:
-- 12 x monthly-normalised value of active and past_due subscriptions,
-- after forever discounts, converted to EUR. Trials, canceled and unpaid count as 0.
CREATE OR REPLACE VIEW billing_arr AS
WITH per_sub AS (
  SELECT
    s.customer_id,
    s.status,
    sum(i.unit_amount * i.quantity) * 12.0 / s.interval_months            AS arr_list,
    sum(i.unit_amount * i.quantity) * 12.0 / s.interval_months
      * (1 - coalesce(max(d.percent_off) FILTER (WHERE d.duration = 'forever'), 0) / 100) AS arr_net
  FROM billing_subscriptions s
  JOIN billing_subscription_items i ON i.subscription_id = s.id
  LEFT JOIN billing_discounts d     ON d.subscription_id = s.id
  GROUP BY s.id, s.customer_id, s.status, s.interval_months
)
SELECT
  c.id                                    AS billing_customer_id,
  c.name                                  AS billing_name,
  c.currency,
  string_agg(DISTINCT p.status, ', ')     AS statuses,
  -- in the customer's own currency
  coalesce(sum(p.arr_net)  FILTER (WHERE p.status IN ('active','past_due')), 0) AS arr_original,
  coalesce(sum(p.arr_list) FILTER (WHERE p.status IN ('active','past_due')), 0) * fx.eur_rate AS arr_list_eur,
  coalesce(sum(p.arr_net)  FILTER (WHERE p.status IN ('active','past_due')), 0) * fx.eur_rate AS arr_eur
FROM billing_customers c
JOIN fx_rates fx USING (currency)
LEFT JOIN per_sub p ON p.customer_id = c.id
GROUP BY c.id, c.name, c.currency, fx.eur_rate;

Step 3: Compare, and give every difference a reason

Now the comparison is simple: per customer, HubSpot ARR next to Stripe ARR, and a rule that says why they differ. On the sample data:

  • HubSpot says €192,600. Stripe says €138,828. A gap of €53,772, about 28%.
  • Only 2 of 11 customers match.
  • Every euro of the gap has a reason.

The sample is built to show each reason once, so the gap is bigger than you'd expect. Yours will be smaller, but it will be made of the same parts:

ReasonWhat happenedFix it at the source
Contract value entered as ARRA two-year deal of €60,000 was typed in as ARRSeparate "contract value" and "ARR" fields in HubSpot
Invoiced outside StripeA customer pays by manual invoice, or isn't linkedLink it, or record it as a manual subscription
Churned in Stripe, customer in HubSpotCancelled in Stripe; HubSpot never heardCancellation in billing updates the CRM stage
Paying, not a customer in HubSpotA self-serve signup still sits as "opportunity"New paying subscription creates or updates the company
Still in trialHubSpot marked it a customer at signupLifecycle moves to customer on first payment
Expansion not in HubSpotTen seats added mid-contractBilling changes update ARR in the CRM
Discount not in HubSpot15% permanent discount, list price in HubSpotARR comes from billing, not the quote
Not linked to a companyA customer paying from a Gmail addressA person links it once; the decision is stored
Amount not convertedA USD amount typed in as EUROne conversion rule, applied by the sync
Show the SQL for step 3

The comparison, with one reason per customer. The rules run top to bottom; the first one that fits wins:

-- Compare CRM and billing per customer, and say why they differ
CREATE OR REPLACE VIEW arr_reconciliation AS
WITH crm AS (
  SELECT l.customer_id, c.name, c.lifecycle_stage, c.arr
  FROM customer_source_ids l
  JOIN crm_companies c ON l.source = 'crm' AND l.source_id = c.id
  WHERE l.status = 'confirmed'
),
bill AS (   -- one row per customer, even if they have several billing accounts
  SELECT l.customer_id,
         min(b.billing_name)                        AS billing_name,
         CASE WHEN count(DISTINCT b.currency) = 1
              THEN min(b.currency) ELSE 'mixed' END AS currency,
         string_agg(DISTINCT b.statuses, ', ')      AS statuses,
         sum(b.arr_original) AS arr_original,
         sum(b.arr_list_eur) AS arr_list_eur,
         sum(b.arr_eur)      AS arr_eur
  FROM customer_source_ids l
  JOIN billing_arr b ON l.source = 'billing' AND l.source_id = b.billing_customer_id
  WHERE l.status = 'confirmed'
  GROUP BY l.customer_id
),
unlinked AS (   -- billing customers nobody has linked yet
  SELECT b.*
  FROM billing_arr b
  WHERE NOT EXISTS (SELECT 1 FROM customer_source_ids l
                    WHERE l.source = 'billing' AND l.source_id = b.billing_customer_id
                      AND l.status = 'confirmed')
),
joined AS (
  SELECT coalesce(crm.name, bill.billing_name)     AS customer,
         crm.lifecycle_stage,
         CASE WHEN crm.lifecycle_stage = 'customer' THEN crm.arr END AS crm_arr,
         bill.statuses, bill.currency,
         bill.arr_original, bill.arr_list_eur, bill.arr_eur,
         crm.customer_id IS NOT NULL  AS in_crm,
         bill.customer_id IS NOT NULL AS in_billing
  FROM crm FULL JOIN bill ON bill.customer_id = crm.customer_id
  UNION ALL
  SELECT u.billing_name, NULL, NULL, u.statuses, u.currency,
         u.arr_original, u.arr_list_eur, u.arr_eur, false, true
  FROM unlinked u
)
SELECT
  customer,
  round(coalesce(crm_arr, 0))                         AS crm_arr,
  round(coalesce(arr_eur, 0))                         AS billing_arr,
  round(coalesce(arr_eur, 0) - coalesce(crm_arr, 0))  AS difference,
  CASE
    WHEN NOT in_crm                                   THEN 'Not linked to a CRM company'
    WHEN lifecycle_stage <> 'customer'                THEN 'Paying, but not a customer in the CRM'
    WHEN crm_arr IS NULL                              THEN 'Customer in the CRM, but no ARR filled in'
    WHEN NOT in_billing                               THEN 'No billing record: invoiced elsewhere, or not linked'
    WHEN statuses ~ 'trialing' AND arr_eur = 0        THEN 'Still in trial in billing'
    WHEN arr_eur = 0                                  THEN 'Churned in billing, still a customer in the CRM'
    WHEN abs(arr_eur - crm_arr) <= greatest(100, 0.02 * arr_eur) THEN 'Matches'
    WHEN abs(arr_list_eur - crm_arr) <= 100           THEN 'Discount not in the CRM'
    WHEN currency NOT IN ('EUR', 'mixed') AND abs(arr_original - crm_arr) <= 100
                                                      THEN 'Amount not converted to EUR'
    WHEN crm_arr IN (arr_eur * 2, arr_eur * 3)        THEN 'Contract value entered as ARR'
    WHEN arr_eur > crm_arr                            THEN 'Expansion in billing, not in the CRM'
    ELSE                                                   'Downgrade in billing, not in the CRM'
  END AS reason,
  statuses AS billing_status
FROM joined
-- leave out records that are neither a CRM customer nor paying
WHERE lifecycle_stage = 'customer' OR coalesce(arr_eur, 0) > 0;

SELECT customer, crm_arr, billing_arr, difference, reason, billing_status
FROM arr_reconciliation
ORDER BY abs(difference) DESC, customer;

Step 4: Fix the cause, not the report

The tempting move is to edit the HubSpot numbers until they match. Don't. Next month they drift again, because whatever caused the difference is still running.

Look at the "fix it at the source" column instead. Almost every line is one of two things:

  1. A missing connection. Billing changes something (a cancellation, an upgrade, a new paying customer) and the CRM never hears about it.
  2. A field with the wrong owner. People type ARR by hand, so it reflects the quote, not the bill.

Fix those, and the reconciliation shrinks to a short list of genuine exceptions.

Where the result goes

A reconciliation nobody sees doesn't fix anything. Send each part of the result to where the work happens:

  • HubSpot: the ARR field on each company is written by the sync from billing, so sales and CS see the number finance sees. Contract value stays in its own field.
  • Slack or email, once a month: the list of differences with their reasons, sent to finance and RevOps before the board pack.
  • One owner: a named person works the list until it's empty, and decides on anything a rule can't, like whether a subsidiary rolls up to its parent.

After a few months the list gets short, and that's the signal the connections hold.

What breaks in production

  • Timing. Compare both systems on the same date. A sync that runs nightly makes yesterday's upgrade look like a difference.
  • Proration and mid-period changes. An upgrade on the 20th changes MRR immediately and the invoice later. Decide which one ARR follows.
  • Annual prepaid contracts. Paid once, but ARR should stay flat for twelve months. Don't build ARR from invoices.
  • Credits and refunds. They change cash, not usually ARR. Keep them out of the comparison or you'll chase noise.
  • Settings changing underneath you. Change Stripe's discount setting and every MRR number changes with it. Write down which settings your definition assumes.
  • New billing accounts. Every new Stripe customer needs a link. Without someone reviewing the unlinked list, it grows.

Three ways to implement it

1. A monthly spreadsheet. Export active subscriptions from Stripe and customer companies from HubSpot, match on domain, and compare. With fewer than about 100 customers, this is honestly enough, as long as someone does it every month.

2. Sync ARR into HubSpot. Several apps calculate MRR per customer from Stripe and write it to the HubSpot company. That fixes the number, but the matching and the definition still need deciding, and you still need the list of exceptions.

3. A separate reporting layer, maintained by someone. A small database with the links, the definition and the comparison, run on a schedule. The most robust option, and the one that needs the most care. It's what we build and maintain at Seamline, but plenty of teams run it in-house.

Technical appendix: run this yourself

Run these in order in an empty Postgres database (a free Supabase project works). Tested on Postgres 16.

1. Sample data (fictional companies and amounts):

-- Sample data: fictional companies, people and amounts.
-- CRM side: a company record with a lifecycle stage and an ARR property
CREATE TABLE crm_companies (
  id              text PRIMARY KEY,
  name            text,
  domain          text,
  lifecycle_stage text,          -- 'customer', 'opportunity', ...
  arr             numeric        -- typed in by sales or set by a workflow, in EUR
);

-- Billing side: customers, subscriptions, line items, discounts
CREATE TABLE billing_customers (
  id       text PRIMARY KEY,
  name     text,
  email    text,
  currency text
);

CREATE TABLE billing_subscriptions (
  id             text PRIMARY KEY,
  customer_id    text REFERENCES billing_customers(id),
  status         text,           -- active, past_due, trialing, canceled, unpaid
  interval_months int            -- 1 = monthly, 12 = yearly
);

CREATE TABLE billing_subscription_items (
  subscription_id text REFERENCES billing_subscriptions(id),
  product         text,
  unit_amount     numeric,       -- per interval, in the customer's currency
  quantity        int
);

CREATE TABLE billing_discounts (
  subscription_id text REFERENCES billing_subscriptions(id),
  percent_off     numeric,
  duration        text           -- forever, repeating, once
);

CREATE TABLE fx_rates (currency text PRIMARY KEY, eur_rate numeric);
INSERT INTO fx_rates VALUES ('EUR', 1), ('USD', 0.92);

INSERT INTO crm_companies VALUES
  ('hs_101', 'Northwind Analytics', 'northwind.io',    'customer',    24000),
  ('hs_102', 'Atlas & Rowe',        'atlasrowe.com',   'customer',    36000),
  ('hs_104', 'Kestrel Logistics',   'kestrel.eu',      'customer',    18000),
  ('hs_105', 'Fjord Studio',        'fjordstudio.no',  'customer',    12000),
  ('hs_106', 'Mosaic Retail',       'mosaicretail.nl', 'customer',     9600),
  ('hs_107', 'Brightpath Learning', 'brightpath.io',   'customer',    60000),
  ('hs_108', 'Corvid Labs',         'corvidlabs.com',  'customer',    12000),
  ('hs_109', 'Helix Freight',       'helixfreight.de', 'opportunity', NULL),
  ('hs_110', 'Oakline Legal',       'oakline.nl',      'customer',    15000),
  ('hs_111', 'Pinecrest Media',     'pinecrest.media', 'customer',     6000);

INSERT INTO billing_customers VALUES
  ('cus_A1', 'NORTHWIND ANALYTICS B.V.', 'finance@northwind.io',    'EUR'),
  ('cus_A2', 'Atlas and Rowe Ltd',       'billing@atlasrowe.com',   'EUR'),
  ('cus_A4', 'Kestrel Logistics',        'ap@kestrel.eu',           'EUR'),
  ('cus_A5', 'Fjord Studio AS',          'post@fjordstudio.no',     'EUR'),
  ('cus_A6', 'Mosaic Retail',            'mosaic.retail@gmail.com', 'EUR'),
  ('cus_A7', 'Brightpath Learning',      'finance@brightpath.io',   'EUR'),
  ('cus_A8', 'Corvid Labs Inc',          'ar@corvidlabs.com',       'USD'),
  ('cus_A9', 'Helix Freight GmbH',       'buchhaltung@helixfreight.de', 'EUR'),
  ('cus_B1', 'Pinecrest Media',          'ops@pinecrest.media',     'EUR'),
  ('cus_B2', 'Jan de Vries',             'j.devries@gmail.com',     'EUR');

INSERT INTO billing_subscriptions VALUES
  ('sub_1', 'cus_A1', 'active',    1),
  ('sub_2', 'cus_A2', 'active',   12),
  ('sub_4', 'cus_A4', 'active',    1),
  ('sub_5', 'cus_A5', 'canceled',  1),
  ('sub_6', 'cus_A6', 'past_due',  1),
  ('sub_7', 'cus_A7', 'active',   12),
  ('sub_8', 'cus_A8', 'active',    1),
  ('sub_9', 'cus_A9', 'active',    1),
  ('sub_10','cus_B1', 'trialing',  1),
  ('sub_11','cus_B2', 'active',    1);

INSERT INTO billing_subscription_items VALUES
  ('sub_1',  'Platform',     2000, 1),
  ('sub_2',  'Platform',    36000, 1),
  ('sub_4',  'Platform',     1500, 1),
  ('sub_4',  'Extra seats',    50, 10),   -- added mid-contract
  ('sub_5',  'Platform',     1000, 1),
  ('sub_6',  'Platform',      800, 1),
  ('sub_7',  'Platform',    30000, 1),
  ('sub_8',  'Platform',     1000, 1),
  ('sub_9',  'Starter',       700, 1),
  ('sub_10', 'Platform',      500, 1),
  ('sub_11', 'Solo',           99, 1);

INSERT INTO billing_discounts VALUES ('sub_2', 15, 'forever');

2. The identity links. In a real setup, rules propose these and a person confirms them; the renewal-risk use case has the SQL for that.

-- The identity layer: one ID per customer, linked to each system's own record.
-- (How these links are made and confirmed is covered in the renewal-risk use case.)
CREATE TABLE customer_source_ids (
  customer_id text,
  source      text,          -- 'crm' or 'billing'
  source_id   text,
  status      text           -- only 'confirmed' links are used
);

INSERT INTO customer_source_ids VALUES
  ('CUST_000001','crm','hs_101','confirmed'), ('CUST_000001','billing','cus_A1','confirmed'),
  ('CUST_000002','crm','hs_102','confirmed'), ('CUST_000002','billing','cus_A2','confirmed'),
  ('CUST_000004','crm','hs_104','confirmed'), ('CUST_000004','billing','cus_A4','confirmed'),
  ('CUST_000005','crm','hs_105','confirmed'), ('CUST_000005','billing','cus_A5','confirmed'),
  ('CUST_000006','crm','hs_106','confirmed'), ('CUST_000006','billing','cus_A6','confirmed'),
  ('CUST_000007','crm','hs_107','confirmed'), ('CUST_000007','billing','cus_A7','confirmed'),
  ('CUST_000008','crm','hs_108','confirmed'), ('CUST_000008','billing','cus_A8','confirmed'),
  ('CUST_000009','crm','hs_109','confirmed'), ('CUST_000009','billing','cus_A9','confirmed'),
  ('CUST_000010','crm','hs_110','confirmed'),
  ('CUST_000011','crm','hs_111','confirmed'), ('CUST_000011','billing','cus_B1','confirmed');
-- cus_B2 (Jan de Vries, gmail) is not linked to anything yet

3. Then run the SQL from steps 2 and 3 above.

4. The summary per reason:

-- The headline: totals, and how much of the gap each reason explains
SELECT reason,
       count(*)          AS customers,
       sum(crm_arr)      AS crm_arr,
       sum(billing_arr)  AS billing_arr,
       sum(difference)   AS difference
FROM arr_reconciliation
GROUP BY ROLLUP (reason)
ORDER BY reason IS NULL, abs(sum(difference)) DESC;

On the sample data, the summary returns nine reasons plus two matching customers, adding up to the €53,772 gap.

Want this done for your data? See how we work → /services/crm-data-cleansing

Want to see what this looks like on your own data?

Pick one question your team currently answers by opening three or four different systems. We'll map where the data lives, how the records connect, and what it would take to answer it reliably.

Book a revenue data diagnostic →