HubSpot and Stripe duplicates: how to merge and match companies when names don't match
8 min read (+ optional SQL)
By Tim, founder of Seamline · Last updated 9 October 2026
In HubSpot, your customer is "Northwind Analytics". It's also "Northwind", created by an integration last year, and "Northwind Analytics" again, created automatically when someone signed up with an app.northwind.io address. In Stripe, the same customer is "NORTHWIND ANALYTICS B.V.", and their German entity pays separately as "Northwind Analytics GmbH".
Nobody did anything wrong. Every tool created the record it needed, in its own way. But now your ARR, your health score and any AI assistant you connect see four or five customers where there is one.
This use case shows how to fix that in four steps, on sample data. You can read it in about eight minutes. The SQL, tested on Postgres, sits behind toggles for anyone who wants to build it.
All companies, people and numbers in this use case are fictional.
Two problems that look like one
"Deduplicate HubSpot and Stripe" is really two different jobs, and they need different fixes:
| Duplicates inside HubSpot | The same company in HubSpot and Stripe | |
|---|---|---|
| What it is | Several HubSpot records for one company | One company, one record in each system |
| The fix | Merge them into one record | Link them, keep both |
| Can you undo it? | No. HubSpot can't unmerge companies | Yes. A link can be removed |
Mixing the two up is where most cleanups go wrong. You can't merge a Stripe customer into a HubSpot company, and you shouldn't try to make one system the copy of the other. What you want is a clearly defined customer model in HubSpot (usually one company per customer, sometimes a parent company with its entities) and a stored link from each Stripe customer to the right record.
Do them in that order: HubSpot first, then the links. When HubSpot merges two companies it normally gives the result a new record ID (HubSpot), so links made before the merge would point to records that no longer exist.
Why the duplicates keep appearing
HubSpot does deduplicate companies, but only in some places:
- Forms, imports and manual creation check the company domain and reuse an existing company if it matches (HubSpot).
- The API and sync apps don't. Companies created through the API, including by installed integrations, aren't deduplicated on domain. This is how "Northwind" appeared next to "Northwind Analytics".
- Automatic company creation makes a company for every unique email domain, subdomains included (HubSpot). One person with an app.northwind.io address is enough for a third record.
- Domains are typed differently.
https://www.northwind.io/andnorthwind.ioare the same domain to you, not to a simple comparison.
Stripe has its own logic. A Stripe customer is whoever pays: a legal entity, a finance mailbox, sometimes an outsourced accountant or a personal Gmail address. And HubSpot's own Stripe integration matches Stripe customers to HubSpot contacts by email address, not to companies (HubSpot Community). Linking billing to the right company is left to you.
How it works
HubSpot companies Stripe customers
│ │
▼ │
1. Find duplicates │
cleaned domain, cleaned name │
│ │
▼ │
2. Merge, with rules for │
which values survive │
│ │
▼ ▼
3. Link each Stripe customer to one HubSpot company
VAT number → billing email domain → name (check only)
│
▼
A person confirms the links that aren't certain
│
▼
4. Stop new duplicates at the sourceStep 1: Find the duplicates inside HubSpot
Before comparing anything, clean the two fields you'll compare:
- Domain: lowercase, without
https://,www.or anything after the first slash, and reduced to the main domain, soapp.northwind.iobecomesnorthwind.io. - Name: lowercase, with
&written as "and", punctuation removed, and legal forms like B.V., GmbH or Ltd dropped.
Then look for pairs of companies that share either one. The sample HubSpot account has 13 companies. The scan finds six possible pairs in four groups:
| Company A | Company B | Why they're a pair |
|---|---|---|
| Northwind Analytics | Northwind | Same domain |
| Northwind Analytics | Northwind Analytics (app.northwind.io) | Same domain |
| Northwind | Northwind Analytics (app.northwind.io) | Same domain |
| Brenner Logistics | Brenner Logistik GmbH | Same domain |
| Tidewell Learning | TideWell Learning B.V. | Same name, one has no domain |
| Orbis Group | Orbis Group Ltd | Same name, different domains: check |
The last row is why this step suggests pairs instead of merging them. Orbis Group in the Netherlands and Orbis Group Ltd in the UK have the same cleaned name, different domains and different countries. They're two different companies. A person looked, and kept them apart.
So the rule is: the same domain is strong evidence; the same name only means "have a look".
If you're on a HubSpot Professional or Enterprise plan, HubSpot's own duplicate tool does a version of this. It compares company domain, name, country, phone and industry, and lets you merge or reject each suggestion (HubSpot). That's often enough. The query below is for when you want the cleaning rules in your own hands, or want to run the same rules against billing data in step 3.
Show the SQL for step 1
Pairs of HubSpot companies that are probably the same company. The cleaning functions norm_domain and norm_name are in the appendix.
-- Step 1: pairs of HubSpot companies that are probably the same company.
CREATE VIEW hubspot_duplicate_pairs AS
SELECT a.hubspot_id AS company_a,
b.hubspot_id AS company_b,
a.name AS name_a,
b.name AS name_b,
CASE
WHEN norm_domain(a.domain) = norm_domain(b.domain)
THEN 'Same domain'
WHEN norm_domain(a.domain) IS NULL OR norm_domain(b.domain) IS NULL
THEN 'Same name, one has no domain'
ELSE 'Same name, different domains: check'
END AS reason
FROM hubspot_companies a
JOIN hubspot_companies b
ON a.hubspot_id < b.hubspot_id
AND ( norm_domain(a.domain) = norm_domain(b.domain)
OR norm_name(a.name) = norm_name(b.name));Step 2: Decide which record survives
Merging in HubSpot can't be undone, so decide the rules before you click. Here's what HubSpot does on its own, and what to decide yourself:
| What HubSpot does when merging companies | What to decide | |
|---|---|---|
| Property values | Keeps the primary record's values; fills gaps from the other | Which record is primary. A sensible rule: the one with the most deals, then the oldest |
| Domain | Keeps the primary domain and adds the other as a secondary domain | Nothing; this is what you want |
| Lifecycle stage | Keeps the stage furthest down the funnel | Check that a stale "customer" stage isn't kept for a company that churned |
| Activities and associations | Keeps everything from both records | Check association limits on large accounts |
| Record ID | Usually gives the merged record a new ID; the old IDs stay searchable | Update anything outside HubSpot that stored the old IDs |
| Undo | Not possible | Merge only pairs a person approved |
Source: HubSpot, merge records.
One more rule: don't overwrite the name sales uses with the legal name from billing. "Northwind Analytics" is what your team searches for. "NORTHWIND ANALYTICS B.V." belongs on invoices. If you need both, keep the legal name in its own field, filled from billing.
In the sample, four merges were approved: both extra Northwind records into Northwind Analytics, Brenner Logistik GmbH into Brenner Logistics, and TideWell Learning B.V. into Tidewell Learning. Thirteen companies become nine.
Step 3: Link each Stripe customer to one HubSpot company
Now the cross-system part. For every Stripe customer, look for evidence that it belongs to a HubSpot company, strongest first:
| Evidence | How strong | How often you'll have it |
|---|---|---|
| The same verified VAT number | Very strong for the legal entity. It doesn't tell you whether that entity is the customer in your CRM | Rarely. Stripe stores tax IDs on customers who gave one, with a verification status (Stripe); HubSpot has no VAT field unless you add one |
| The billing email's domain matches the company's domain | Strong, when the domain is known to belong to the customer. Not for free email, shared finance mailboxes or accountants | Most of the time |
| The same cleaned name | Weak. A suggestion for a person to check | Often, which is exactly the danger |
The strongest evidence wins, with two exceptions. If two HubSpot companies fit equally well, the customer is ambiguous. If the VAT number and the domain point to different companies, it's a conflict. Neither gets guessed. Keep a short list of email domains that never identify a company: Gmail, Outlook, and the accountants your customers use.
On the sample data:
| Stripe customer | HubSpot company | Evidence | Confidence |
|---|---|---|---|
| NORTHWIND ANALYTICS B.V. | Northwind Analytics | Same verified VAT number | Strong |
| Brenner Logistik GmbH | Brenner Logistics | Billing email domain = company domain | Strong |
| Saltmarsh | Saltmarsh Studio | Billing email domain = company domain | Strong |
| Orbis Group B.V. | Orbis Group | Billing email domain = company domain | Strong |
| Tidewell Learning | — | Conflict: the email says Tidewell Learning, the VAT number says Tidewell Holding | Check |
| Harrow and Finch Ltd | Harrow & Finch | Same name only (billing goes to their accountant) | Weak: check |
| Juniper Retail | Juniper Retail | Same name only (pays from a Gmail address) | Weak: check |
| Northwind Analytics GmbH | Northwind Analytics | Same name only (German entity, own domain) | Weak: check |
| Orbis Group Ltd | — | Ambiguous: two companies fit | Check |
| Ostra Freight | — | No match: paying, but not in HubSpot | Check |
Four links are strong. Six need a person, and the table shows why for each one.
Tidewell is the interesting case. The billing email belongs to Tidewell Learning, but the verified VAT number belongs to Tidewell Holding, which pays the subscription for its subsidiary. Both pieces of evidence are true. They just answer different questions: the VAT number tells you which legal entity pays; it doesn't tell you who your customer is.
Northwind Analytics GmbH raises the same question from the other side. A strong identity match tells you which legal entity a record represents. Whether that entity rolls up to the same customer is a separate business rule. Most teams link a subsidiary to the same customer so its revenue adds up; some keep entities apart for invoicing. Either is fine, as long as it's decided once and written down.
For comparison: on this sample dataset, matching on names exactly as they're typed, against HubSpot as it was before the cleanup, links only 4 of the 10 Stripe customers. And one of those links points to Brenner Logistik GmbH, the duplicate that's about to be merged away.
Store the links in a small table of their own, with a status, proposed or confirmed, and only count confirmed ones. The renewal-risk use case shows that table and the review step in detail. Once the links exist, you can reconcile ARR between HubSpot and Stripe and build a customer health score on top of them.
Show the SQL for step 3
Every pair of Stripe customer and HubSpot company with a piece of evidence, ranked:
-- Step 3: propose a HubSpot company for every Stripe customer.
-- Strongest evidence first; the best tier per customer wins.
CREATE VIEW stripe_match_candidates AS
WITH s AS (
SELECT stripe_id, name,
norm_name(name) AS n_name,
norm_tax(tax_id) AS n_tax,
tax_status,
norm_domain(split_part(email, '@', 2)) AS n_domain
FROM stripe_customers
), pairs AS (
SELECT s.stripe_id, h.hubspot_id,
CASE
WHEN s.n_tax IS NOT NULL AND s.tax_status = 'verified'
AND s.n_tax = norm_tax(h.vat_number) THEN 1
WHEN s.n_domain = ANY (h.domains)
AND s.n_domain NOT IN (SELECT domain FROM ignored_email_domains) THEN 2
WHEN s.n_name = norm_name(h.name) THEN 3
END AS tier
FROM s CROSS JOIN hubspot_current h
)
SELECT stripe_id, hubspot_id, tier
FROM pairs
WHERE tier IS NOT NULL;
-- One proposal per Stripe customer. If the strong evidence (VAT, domain)
-- points to different companies, nothing wins: a person decides.
CREATE VIEW stripe_match_proposals AS
WITH summary AS (
SELECT stripe_id,
min(tier) AS best_tier,
count(DISTINCT hubspot_id) FILTER (WHERE tier IN (1, 2)) AS strong_companies
FROM stripe_match_candidates
GROUP BY stripe_id
), best AS (
SELECT c.stripe_id, count(*) AS n, min(c.hubspot_id) AS hubspot_id
FROM stripe_match_candidates c
JOIN summary s ON s.stripe_id = c.stripe_id AND c.tier = s.best_tier
GROUP BY c.stripe_id
)
SELECT sc.stripe_id,
sc.name AS stripe_name,
CASE WHEN s.strong_companies <= 1 AND b.n = 1 THEN b.hubspot_id END AS hubspot_id,
CASE
WHEN s.best_tier IS NULL THEN 'No match: paying, not in HubSpot?'
WHEN s.strong_companies > 1 THEN 'Conflict: VAT and domain point to different companies'
WHEN b.n > 1 THEN 'Ambiguous: several companies fit'
WHEN s.best_tier = 1 THEN 'Same verified VAT number'
WHEN s.best_tier = 2 THEN 'Billing email domain = company domain'
ELSE 'Same name only'
END AS evidence,
CASE
WHEN s.best_tier IS NULL OR s.strong_companies > 1 OR b.n > 1 THEN 'check'
WHEN s.best_tier IN (1, 2) THEN 'strong'
ELSE 'weak: check'
END AS confidence
FROM stripe_customers sc
LEFT JOIN summary s ON s.stripe_id = sc.stripe_id
LEFT JOIN best b ON b.stripe_id = sc.stripe_id;On the sample data this returns four strong links, three weak ones to check, one conflict, one ambiguous customer and one with no match, as in the table above.
Step 4: Keep it from coming back
A cleanup that isn't protected lasts a few months. Each of these closes one of the causes above:
- Integrations look before they create. Anything that creates companies through the API should search by domain first and update the existing company if there is one.
- Decide what automatic company creation is allowed to do. Either exclude the subdomains and domains that keep creating noise, or turn it off and create companies on purpose.
- Clean the domain when it's entered. Same rule as step 1, applied in a workflow or the integration, so
https://www.never reaches the field. - Write the link back. Store the HubSpot company ID on the Stripe customer, in its metadata. The next time, the match is exact instead of guessed.
- Review the leftovers weekly. New Stripe customers without a confirmed link, and new duplicate pairs. With a clean base it's a five-minute list.
What breaks in production
- Merging too eagerly. A shared name or a parent company's domain doesn't make two records the same company. Since merges can't be undone, wrong merges are the most expensive mistake here.
- Group structures. Holdings, subsidiaries and franchises share names and sometimes domains. Decide whether you track them as one customer or several before you start.
- Rebrands and acquisitions. A customer changes domain; old invoices still go to the old one. Keep secondary domains, don't replace them.
- Country-specific domains. northwind.io and northwind.de can be one company or two. The domain alone can't tell you.
- Old IDs elsewhere. Spreadsheets, BI tools and integrations that stored HubSpot company IDs before a merge silently stop matching.
Three ways to implement it
1. HubSpot's duplicate tool, and a spreadsheet for Stripe. On a Professional or Enterprise plan, review HubSpot's duplicate suggestions, then export Stripe customers and match them by hand on domain. With fewer than about 100 customers, this is enough.
2. A data quality or sync app. Several apps deduplicate HubSpot and sync Stripe customers to companies. They save time, but check what they match on, and whether they suggest or merge automatically.
3. A small matching layer, maintained by someone. The cleaning rules, the matching and the confirmed links in a small database, re-run as new customers arrive. 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.
Want to see how your customers match up?
In the diagnostic, we run these rules on an export of your HubSpot companies and Stripe customers. You see how many link with confidence, which ones need a decision and why, and what keeps creating new duplicates.
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 numbers):
-- Sample data. All companies, people and numbers are fictional.
CREATE TABLE hubspot_companies (
hubspot_id bigint PRIMARY KEY,
name text NOT NULL,
domain text, -- "Company domain name", as typed or synced
country text,
vat_number text, -- custom property, rarely filled in
created_by text, -- form, import, integration, auto (from contact email)
deals int DEFAULT 0, -- associated deals
created_at date
);
INSERT INTO hubspot_companies VALUES
(101, 'Northwind Analytics', 'northwind.io', 'NL', 'NL859301234B01', 'form', 3, '2024-02-12'),
(102, 'Northwind', 'https://www.northwind.io/', 'NL', NULL, 'integration', 0, '2025-06-03'),
(103, 'Northwind Analytics', 'app.northwind.io', NULL, NULL, 'auto', 0, '2026-01-20'),
(201, 'Tidewell Learning', 'tidewell.com', 'NL', NULL, 'form', 2, '2024-05-09'),
(202, 'TideWell Learning B.V.', NULL, 'NL', NULL, 'import', 1, '2025-11-14'),
(301, 'Juniper Retail', 'juniperretail.nl', 'NL', NULL, 'form', 1, '2025-03-01'),
(401, 'Brenner Logistics', 'brenner-logistics.de', 'DE', NULL, 'form', 2, '2024-09-17'),
(402, 'Brenner Logistik GmbH', 'Brenner-Logistics.de', 'DE', NULL, 'import', 0, '2025-02-26'),
(501, 'Harrow & Finch', 'harrowfinch.com', 'GB', NULL, 'form', 1, '2024-11-04'),
(601, 'Saltmarsh Studio', 'saltmarsh.studio', 'NO', NULL, 'form', 1, '2025-08-22'),
(801, 'Orbis Group', 'orbisgroup.nl', 'NL', NULL, 'form', 1, '2025-04-15'),
(802, 'Orbis Group Ltd', 'orbis-group.co.uk', 'GB', NULL, 'import', 1, '2025-10-02'),
(901, 'Tidewell Holding', 'tidewell-group.com', 'NL', 'NL004417352B01', 'form', 0, '2025-01-08');
CREATE TABLE stripe_customers (
stripe_id text PRIMARY KEY,
name text,
email text,
tax_id text, -- from the customer's tax IDs, e.g. eu_vat
tax_status text -- verified, unverified, pending, unavailable
);
INSERT INTO stripe_customers VALUES
('cus_N1', 'NORTHWIND ANALYTICS B.V.', 'billing@northwind.io', 'NL859301234B01', 'verified'),
('cus_N2', 'Northwind Analytics GmbH', 'finance@northwind.de', 'DE318274650', 'verified'),
('cus_T1', 'Tidewell Learning', 'accounts@tidewell.com', 'NL004417352B01', 'verified'),
('cus_J1', 'Juniper Retail', 'juniper.retail@gmail.com', NULL, NULL),
('cus_R1', 'Brenner Logistik GmbH', 'rechnung@brenner-logistics.de', 'DE291746385', 'verified'),
('cus_H1', 'Harrow and Finch Ltd', 'ap@numbers-accounting.co.uk', NULL, NULL),
('cus_O1', 'Orbis Group B.V.', 'finance@orbisgroup.nl', NULL, NULL),
('cus_S1', 'Saltmarsh', 'hello@saltmarsh.studio', NULL, NULL),
('cus_O2', 'Orbis Group Ltd', 'orbisgroupuk@gmail.com', NULL, NULL),
('cus_Q1', 'Ostra Freight', 'ops@ostrafreight.com', NULL, NULL);
-- Domains that never identify a company.
CREATE TABLE ignored_email_domains (domain text PRIMARY KEY, reason text);
INSERT INTO ignored_email_domains VALUES
('gmail.com','free email'), ('outlook.com','free email'), ('hotmail.com','free email'),
('yahoo.com','free email'), ('icloud.com','free email'),
('numbers-accounting.co.uk','outsourced accountant');2. The cleaning functions. The domain rule keeps three labels for a few two-part endings like co.uk; a production version should use the full public suffix list.
-- Domain: lowercase, strip protocol, www and path, keep the registrable domain.
CREATE FUNCTION norm_domain(raw text) RETURNS text
LANGUAGE sql IMMUTABLE AS $$
WITH host AS (
SELECT regexp_replace(
regexp_replace(lower(trim(raw)), '^https?://', ''),
'[/:?#].*$', '') AS h
), parts AS (
SELECT string_to_array(regexp_replace(h, '^www\.', ''), '.') AS p FROM host
)
SELECT CASE
WHEN raw IS NULL OR trim(raw) = '' THEN NULL
-- two-part public suffixes like co.uk: keep three labels
WHEN array_length(p, 1) >= 3
AND p[array_length(p,1)-1] || '.' || p[array_length(p,1)] IN ('co.uk','com.au','co.nz','com.br')
THEN array_to_string(p[array_length(p,1)-2:array_length(p,1)], '.')
WHEN array_length(p, 1) >= 2
THEN array_to_string(p[array_length(p,1)-1:array_length(p,1)], '.')
ELSE array_to_string(p, '.')
END
FROM parts
$$;
-- Name: lowercase, '&' to 'and', drop punctuation and legal forms.
CREATE FUNCTION norm_name(raw text) RETURNS text
LANGUAGE sql IMMUTABLE AS $$
SELECT nullif(trim(regexp_replace(
regexp_replace(
regexp_replace(replace(lower(raw), '&', ' and '), '[^a-z0-9 ]', ' ', 'g'),
'\m(b ?v|bv|n ?v|gmbh|ltd|limited|inc|llc|sa|sas|ab|as|oy)\M', ' ', 'g'),
'\s+', ' ', 'g')), '')
$$;
-- Tax IDs: uppercase, no spaces or dots.
CREATE FUNCTION norm_tax(raw text) RETURNS text
LANGUAGE sql IMMUTABLE AS $$
SELECT nullif(upper(regexp_replace(raw, '[^A-Za-z0-9]', '', 'g')), '')
$$;3. The approved merges, and HubSpot as it looks afterwards:
-- The merges a person approved after reviewing step 1.
-- Orbis Group (801) and Orbis Group Ltd (802) were reviewed and kept apart:
-- different domains, different countries, different companies.
CREATE TABLE approved_merges (
secondary_id bigint PRIMARY KEY,
primary_id bigint NOT NULL
);
INSERT INTO approved_merges VALUES (102, 101), (103, 101), (402, 401), (202, 201);
-- HubSpot after the merges: one record per company, the secondaries' domains kept.
CREATE VIEW hubspot_current AS
SELECT c.hubspot_id, c.name, c.vat_number,
array_remove(array_agg(DISTINCT norm_domain(d.domain)), NULL) AS domains
FROM hubspot_companies c
LEFT JOIN approved_merges m ON m.primary_id = c.hubspot_id
JOIN hubspot_companies d ON d.hubspot_id IN (c.hubspot_id, m.secondary_id)
WHERE c.hubspot_id NOT IN (SELECT secondary_id FROM approved_merges)
GROUP BY c.hubspot_id, c.name, c.vat_number;4. Then run the SQL from steps 1 and 3 above.
Want this done for your data? See how we work → /services/crm-data-cleansing
Want this fixed on your data? Book a diagnostic.
Book a diagnosticRelated question: “What is each customer really worth?”