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:
| Customer | HubSpot | Stripe | Difference | Why |
|---|---|---|---|---|
| Brightpath Learning | €60,000 | €30,000 | −€30,000 | Two-year contract value entered as ARR |
| Fjord Studio | €12,000 | €0 | −€12,000 | Cancelled in Stripe, still a customer in HubSpot |
| Helix Freight | — | €8,400 | +€8,400 | Paying, but not a customer in HubSpot |
| Kestrel Logistics | €18,000 | €24,000 | +€6,000 | Extra 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 ARR | Stripe MRR × 12 | |
|---|---|---|
| What it is | What someone entered, usually when the deal closed | Calculated from the subscriptions running right now |
| Updated when | A person or a workflow changes it | Billing changes: upgrades, cancellations, discounts |
| Often includes | Contract value, list price, one-off fees | Only recurring amounts, normalised to a month |
| Often misses | Mid-contract upgrades, downgrades, churn | Customers 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 ownerThe 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.
Step 1: Link the customer
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.comor 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 here | Decide with your team | |
|---|---|---|
| Counts | Subscriptions that are active or past_due | Should past_due count? Stripe says yes until it becomes unpaid |
| Amount | Recurring line items × quantity, normalised to a year | Do one-off setup fees ever count? (Usually not) |
| Discounts | Permanent discounts subtracted | What about discounts that expire in three months? |
| Excluded | Trials, cancelled and unpaid subscriptions, tax | Usage-based revenue: separate number, or a trailing average? |
| Currency | Converted to EUR at a fixed rate | Which 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:
| Reason | What happened | Fix it at the source |
|---|---|---|
| Contract value entered as ARR | A two-year deal of €60,000 was typed in as ARR | Separate "contract value" and "ARR" fields in HubSpot |
| Invoiced outside Stripe | A customer pays by manual invoice, or isn't linked | Link it, or record it as a manual subscription |
| Churned in Stripe, customer in HubSpot | Cancelled in Stripe; HubSpot never heard | Cancellation in billing updates the CRM stage |
| Paying, not a customer in HubSpot | A self-serve signup still sits as "opportunity" | New paying subscription creates or updates the company |
| Still in trial | HubSpot marked it a customer at signup | Lifecycle moves to customer on first payment |
| Expansion not in HubSpot | Ten seats added mid-contract | Billing changes update ARR in the CRM |
| Discount not in HubSpot | 15% permanent discount, list price in HubSpot | ARR comes from billing, not the quote |
| Not linked to a company | A customer paying from a Gmail address | A person links it once; the decision is stored |
| Amount not converted | A USD amount typed in as EUR | One 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:
- A missing connection. Billing changes something (a cancellation, an upgrade, a new paying customer) and the CRM never hears about it.
- 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 yet3. 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 →