Your CRM has 50,000 company records. 30% have wrong industries, 20% have outdated employee counts, and 15% are duplicates or defunct companies. Your sales team is routing leads to the wrong reps. Your marketing campaigns hit dead ends. Your pipeline reports are fiction.
Cleaning this manually would take months. A single data analyst reviewing 200 records per day would need over a year to get through the backlog — and by then, the data would be stale again.
An API can do it in hours.
This guide walks through exactly how to validate company data using an API, step by step. We'll cover the workflow, the code, and the ongoing maintenance strategy that keeps your B2B data clean at scale — without burning headcount on manual review.
The Scale of the B2B Data Quality Problem
Bad data isn't a minor inconvenience. It's a structural problem that compounds across every team that touches your CRM.
According to Gartner, the average financial impact of poor data quality on organizations is $15 million per year. That number accounts for wasted marketing spend, misrouted sales efforts, failed integrations, compliance risks, and the labor cost of manual cleanup.
Forrester research shows that nearly 1 in 3 analysts spend more than 40% of their time vetting and validating data before it can be used for analysis. That's not analytics work — that's janitorial work.
Here's what bad B2B data quality actually looks like in practice:
- Sales reps waste 27% of their time on leads with incorrect or missing firmographic data (HubSpot)
- Duplicate records inflate pipeline forecasts, leading to over-hiring and budget misallocation
- Outdated company information means your ICP filters are selecting the wrong accounts
- Defunct companies in your CRM skew win-rate calculations and territory planning
- Wrong industry classifications break segment-specific campaigns and routing rules
The problem isn't that companies don't care about data quality. It's that manual cleaning doesn't scale. When you're dealing with tens of thousands of records that decay at a rate of 30% per year, you need an automated system. That system starts with an API.
5 Common Data Quality Issues in B2B Databases
Before you can clean B2B data at scale, you need to know what you're cleaning. These are the five most common issues we see when companies audit their CRM data.
1. Wrong Industry Classification
This is the most pervasive issue. A company that was originally tagged as "Marketing Agency" three years ago may have pivoted to "SaaS" or expanded into "Consulting." Many CRMs rely on the industry that was assigned at the point of lead capture — often self-reported by the prospect on a form — and never update it.
The downstream impact: your industry-specific campaigns reach the wrong audience, your sales reps pitch irrelevant case studies, and your market analysis is built on sand.
2. Outdated Employee Counts
Employee count is a core firmographic field used for segmentation, lead scoring, and territory assignment. A startup that had 15 employees when it entered your CRM may now have 500. A mid-market company that was at 2,000 may have gone through layoffs and is now at 400.
If your scoring model weights company size heavily (and most do), stale employee counts mean your SDRs are calling the wrong accounts.
3. Defunct or Acquired Companies
Companies shut down. Companies get acquired. The domains stop resolving, the LinkedIn pages go dark, and the logos disappear — but the CRM record lives on forever. These ghost records pollute your total addressable market calculations and waste outreach efforts.
In any database older than two years, we typically find 8–15% of records belong to companies that no longer exist as independent entities.
4. Duplicate Records
Duplicates creep in from multiple sources: different reps entering the same company under slightly different names ("McKinsey & Company" vs "McKinsey" vs "McKinsey and Co"), imports from different tools, or the same company being captured through different marketing channels.
Duplicates don't just waste storage — they split activity history across records, making it impossible to get a complete view of your engagement with a company.
5. Missing or Incomplete Fields
Partial records are everywhere. You might have a company name and domain but no industry, no employee count, no headquarters location, and no funding information. These records are unusable for segmentation and scoring but sit in your CRM taking up space and skewing reports.
API-Based Data Validation Workflow
The core workflow for company data validation at scale follows four stages. Each stage can be automated, and the entire pipeline can run on a schedule.
┌─────────────┐ ┌──────────────┐ ┌───────────────┐ ┌──────────────┐
│ EXTRACT │───▶│ ENRICH │───▶│ COMPARE │───▶│ UPDATE │
│ │ │ │ │ │ │ │
│ Export CRM │ │ Batch API │ │ Old vs New │ │ Write back │
│ records │ │ lookup by │ │ data diff │ │ to CRM or │
│ (CSV/API) │ │ domain │ │ + flag issues │ │ flag review │
└─────────────┘ └──────────────┘ └───────────────┘ └──────────────┘
This architecture works regardless of your CRM (Salesforce, HubSpot, Pipedrive, custom) and regardless of which enrichment API you use. The domain name is the canonical key that ties everything together.
Let's break down each step with concrete implementation details.
Step-by-Step Implementation
Step 1: Export and Audit Current Data
Start by getting a clear picture of what you have. Export your company records and run a quick audit to identify the gaps.
import pandas as pd
# Load your CRM export
companies = pd.read_csv('crm_companies_export.csv')
# Audit: check completeness of key fields
fields_to_check = ['domain', 'industry', 'employee_count',
'headquarters', 'founded_year', 'funding_total']
audit_report = {}
for field in fields_to_check:
total = len(companies)
missing = companies[field].isna().sum()
filled = total - missing
audit_report[field] = {
'total': total,
'filled': filled,
'missing': missing,
'completeness': f"{(filled/total)*100:.1f}%"
}
audit_df = pd.DataFrame(audit_report).T
print(audit_df)
# Identify records without a domain (these can't be enriched via API)
no_domain = companies[companies['domain'].isna()]
print(f"\n{len(no_domain)} records have no domain — manual review required")
# Flag potential duplicates by domain
duplicate_domains = companies[companies['domain'].duplicated(keep=False)]
print(f"{len(duplicate_domains)} records share a domain with another record")
This audit gives you three critical numbers:
- Completeness rate per field — which fields need enrichment
- Records without domains — these need manual review since domain-based APIs can't help
- Duplicate domain count — your deduplication backlog
Most teams discover that 20–40% of their records are missing at least one critical field. That's your enrichment target.
Step 2: Batch Enrich via API
With your audit complete, the next step is to send every domain through a domain-based company lookup API to get fresh, validated data.
import requests
import time
import json
API_KEY = 'your_api_key'
BASE_URL = 'https://api.bouncewatch.com/v1/company'
def enrich_company(domain):
"""Fetch fresh company data from API using domain as key."""
try:
response = requests.get(
f"{BASE_URL}/lookup",
params={'domain': domain},
headers={'Authorization': f'Bearer {API_KEY}'}
)
if response.status_code == 200:
return response.json()
elif response.status_code == 404:
return {'domain': domain, 'status': 'not_found'}
else:
return {'domain': domain, 'status': 'error',
'code': response.status_code}
except Exception as e:
return {'domain': domain, 'status': 'error', 'message': str(e)}
def batch_enrich(domains, rate_limit=5):
"""Enrich a list of domains with rate limiting."""
results = []
for i, domain in enumerate(domains):
result = enrich_company(domain)
results.append(result)
# Rate limiting: N requests per second
if (i + 1) % rate_limit == 0:
time.sleep(1)
# Progress logging
if (i + 1) % 500 == 0:
print(f"Processed {i+1}/{len(domains)} domains...")
return results
# Get unique domains that need enrichment
domains_to_enrich = companies['domain'].dropna().unique().tolist()
print(f"Enriching {len(domains_to_enrich)} unique domains...")
enriched_data = batch_enrich(domains_to_enrich)
# Save results for comparison
with open('enriched_results.json', 'w') as f:
json.dump(enriched_data, f, indent=2)
print(f"Enrichment complete. {len(enriched_data)} records processed.")
Key considerations for batch enrichment:
- Rate limiting — Respect the API's rate limits. Most enrichment APIs allow 5–10 requests per second. Build in pauses.
- Error handling — APIs return 404s for unknown domains and 429s when you hit rate limits. Handle both gracefully.
- Caching — Store enriched results locally. You don't want to re-enrich the same domain twice in the same batch.
- Domain as key — Always use the company domain as the canonical identifier. Company names are ambiguous; domains are not.
For a 50,000-record database with ~35,000 unique domains, this process typically completes in 2–3 hours at a rate of 5 requests per second.
Step 3: Compare and Flag Discrepancies
Now you have two datasets: your existing CRM data and the fresh API data. The next step is to compare them field by field and flag discrepancies for review or automatic update.
import pandas as pd
import json
# Load enriched data
with open('enriched_results.json', 'r') as f:
enriched = json.load(f)
enriched_df = pd.DataFrame(enriched)
# Merge on domain
merged = companies.merge(
enriched_df,
on='domain',
how='left',
suffixes=('_crm', '_api')
)
# Define comparison rules
def compare_fields(row):
flags = []
# Industry mismatch
if (pd.notna(row.get('industry_crm')) and
pd.notna(row.get('industry_api')) and
row['industry_crm'].lower() != row['industry_api'].lower()):
flags.append('industry_mismatch')
# Employee count drift (>25% change)
if (pd.notna(row.get('employee_count_crm')) and
pd.notna(row.get('employee_count_api'))):
old = float(row['employee_count_crm'])
new = float(row['employee_count_api'])
if old > 0 and abs(new - old) / old > 0.25:
flags.append('employee_count_drift')
# Company status (acquired/shut down)
status = row.get('status_api', '')
if status in ['acquired', 'closed', 'inactive']:
flags.append(f'company_{status}')
# Missing fields that API can fill
for field in ['industry', 'employee_count', 'headquarters', 'founded_year']:
crm_field = f'{field}_crm'
api_field = f'{field}_api'
if pd.isna(row.get(crm_field)) and pd.notna(row.get(api_field)):
flags.append(f'{field}_can_fill')
return flags
merged['validation_flags'] = merged.apply(compare_fields, axis=1)
merged['flag_count'] = merged['validation_flags'].apply(len)
# Summary
flagged = merged[merged['flag_count'] > 0]
print(f"Total records: {len(merged)}")
print(f"Records with issues: {len(flagged)} ({len(flagged)/len(merged)*100:.1f}%)")
print(f"\nFlag distribution:")
all_flags = [f for flags in merged['validation_flags'] for f in flags]
print(pd.Series(all_flags).value_counts())
This comparison step is where the real value emerges. You'll typically see results like:
- 12–18% of records have industry mismatches
- 25–35% of records have employee count drift exceeding 25%
- 5–10% of records are flagged as acquired, closed, or inactive
- 15–30% of records have fields that the API can fill in
As covered in our guide on keeping company data fresh, B2B company data decays at roughly 30% per year. If your last cleanup was 18 months ago, expect a high flag rate.
Step 4: Update CRM with Clean Data
You have two options for the update step: fully automated or human-in-the-loop. The right choice depends on your confidence level and your CRM's importance as a system of record.
import requests
CRM_API_URL = 'https://api.hubspot.com/crm/v3/objects/companies'
CRM_API_KEY = 'your_crm_api_key'
def categorize_updates(flagged_records):
"""Split updates into auto-apply and manual-review buckets."""
auto_apply = []
manual_review = []
for _, record in flagged_records.iterrows():
flags = record['validation_flags']
# Auto-apply: filling in missing fields (no conflict)
fill_flags = [f for f in flags if f.endswith('_can_fill')]
# Auto-apply: small employee count adjustments
conflict_flags = [f for f in flags if f not in fill_flags]
if conflict_flags:
# Has conflicts — needs human review
manual_review.append(record)
else:
# Only filling gaps — safe to auto-apply
auto_apply.append(record)
return auto_apply, manual_review
def apply_updates(records, dry_run=True):
"""Push validated updates to CRM."""
updates = []
for record in records:
update = {
'id': record['crm_record_id'],
'properties': {}
}
# Map API fields to CRM fields
field_mapping = {
'industry_api': 'industry',
'employee_count_api': 'numberofemployees',
'headquarters_api': 'city',
'founded_year_api': 'founded_year'
}
for api_field, crm_field in field_mapping.items():
if pd.notna(record.get(api_field)):
# Only update if CRM field was empty (gap-fill)
crm_source = api_field.replace('_api', '_crm')
if pd.isna(record.get(crm_source)):
update['properties'][crm_field] = record[api_field]
if update['properties']:
updates.append(update)
if dry_run:
print(f"DRY RUN: Would update {len(updates)} records")
for u in updates[:5]:
print(f" Record {u['id']}: {u['properties']}")
return
# Batch update via CRM API
for update in updates:
response = requests.patch(
f"{CRM_API_URL}/{update['id']}",
json={'properties': update['properties']},
headers={'Authorization': f'Bearer {CRM_API_KEY}'}
)
if response.status_code != 200:
print(f"Failed to update {update['id']}: {response.text}")
# Categorize and apply
auto_apply, manual_review = categorize_updates(flagged)
print(f"Auto-apply: {len(auto_apply)} records (gap fills only)")
print(f"Manual review: {len(manual_review)} records (conflicts)")
# Run dry first, then apply
apply_updates(auto_apply, dry_run=True)
Best practice: always run in dry_run mode first. Review the output. Then flip the flag to apply. For records with conflicts (e.g., your CRM says "Fintech" but the API says "Banking"), send those to a queue for human review rather than overwriting automatically.
If you want to fully automate this pipeline, consider building a dedicated enrichment pipeline that handles the extract-enrich-compare-update cycle on a recurring schedule.
Deduplication Strategy
Deduplication is one of the hardest parts of B2B data quality management. Company names are unreliable identifiers — the same company can appear as "JP Morgan", "JPMorgan Chase", "J.P. Morgan & Co.", or "JPMC" across different records.
The solution: use the domain as your canonical identifier.
Domain-Based Deduplication
from urllib.parse import urlparse
def normalize_domain(domain):
"""Normalize domain to bare root format."""
if not domain:
return None
domain = domain.lower().strip()
# Remove protocol if present
if '://' in domain:
domain = urlparse(domain).netloc
# Remove www prefix
if domain.startswith('www.'):
domain = domain[4:]
# Remove trailing slash
domain = domain.rstrip('/')
return domain
# Normalize all domains
companies['domain_normalized'] = companies['domain'].apply(normalize_domain)
# Find duplicates
dupes = companies[companies['domain_normalized'].duplicated(keep=False)]
dupe_groups = dupes.groupby('domain_normalized')
print(f"Found {len(dupe_groups)} domains with duplicate records")
print(f"Total duplicate records: {len(dupes)}")
# Merge strategy: keep the record with most complete data
def select_primary(group):
"""Select the primary record from a group of duplicates."""
# Score each record by completeness
key_fields = ['industry', 'employee_count', 'headquarters',
'founded_year', 'description']
group['completeness'] = group[key_fields].notna().sum(axis=1)
# Prefer record with most activity (deals, contacts, notes)
if 'deal_count' in group.columns:
group['activity_score'] = group['deal_count'].fillna(0)
else:
group['activity_score'] = 0
# Primary = highest completeness, then highest activity
primary_idx = group.sort_values(
['completeness', 'activity_score'],
ascending=False
).index[0]
return primary_idx
# Generate merge plan
merge_plan = []
for domain, group in dupe_groups:
primary_idx = select_primary(group)
secondary_ids = [idx for idx in group.index if idx != primary_idx]
merge_plan.append({
'domain': domain,
'primary_id': companies.loc[primary_idx, 'crm_record_id'],
'merge_ids': [companies.loc[i, 'crm_record_id'] for i in secondary_ids],
'record_count': len(group)
})
print(f"\nMerge plan: {len(merge_plan)} merges to execute")
Handling Subsidiaries vs. Parent Companies
One complexity in deduplication: subsidiaries. "cloud.google.com" and "google.com" are different domains but the same parent organization. "aws.amazon.com" and "amazon.com" present the same challenge.
Rules for handling this:
- If you sell to the subsidiary directly, keep it as a separate record but link it to the parent
- If you sell to the parent, merge subsidiary records into the parent and note the subsidiary relationship
- Use the API's parent company field to identify these relationships automatically
- Never auto-merge parent and subsidiary records — always flag for human review
Most enrichment APIs return a parent_company or ultimate_parent field that makes this identification straightforward. Use it to build a hierarchy rather than a flat deduplication.
Identifying Defunct Companies
Defunct companies are silent killers in your database. They don't bounce emails (the domain may still resolve), they don't unsubscribe, and they never respond. They just sit there, inflating your total addressable market and wasting outreach capacity.
Here's how to detect and flag them systematically:
Shutdown and Acquisition Signals
def detect_defunct(enriched_record):
"""Check multiple signals to determine if a company is defunct."""
signals = {
'is_defunct': False,
'confidence': 'low',
'reason': None,
'signals': []
}
# Signal 1: API explicitly marks as closed/acquired
status = enriched_record.get('operating_status', '').lower()
if status in ['closed', 'acquired', 'inactive', 'defunct']:
signals['signals'].append(f'api_status_{status}')
signals['is_defunct'] = True
signals['confidence'] = 'high'
signals['reason'] = f'Company marked as {status}'
# Signal 2: Domain no longer resolves
if enriched_record.get('domain_status') == 'inactive':
signals['signals'].append('domain_inactive')
signals['is_defunct'] = True
signals['confidence'] = 'high'
signals['reason'] = 'Domain no longer resolves'
# Signal 3: Employee count dropped to 0 or near-0
emp_count = enriched_record.get('employee_count', None)
if emp_count is not None and emp_count <= 2:
signals['signals'].append('near_zero_employees')
if not signals['is_defunct']:
signals['is_defunct'] = True
signals['confidence'] = 'medium'
signals['reason'] = 'Employee count near zero'
# Signal 4: No social media activity in 12+ months
last_activity = enriched_record.get('last_social_activity')
if last_activity and days_since(last_activity) > 365:
signals['signals'].append('social_inactive_12m')
# Signal 5: Company acquired — check acquisition data
if enriched_record.get('acquired_by'):
signals['signals'].append('acquired')
signals['is_defunct'] = True
signals['confidence'] = 'high'
signals['reason'] = f"Acquired by {enriched_record['acquired_by']}"
return signals
BounceWatch tracks shutdown risk signals that can proactively alert you when a company in your CRM shows signs of trouble — layoffs, funding drought, leadership departure — before the company actually shuts down.
What to Do with Defunct Records
Don't delete defunct records outright. Instead:
- Tag them with a "Defunct" or "Inactive" status in your CRM
- Remove them from active campaigns and sequences
- Exclude them from reporting on total addressable market and pipeline coverage
- Archive them after 90 days if no one objects
- For acquired companies, create a relationship record linking to the acquiring company
This preserves historical data while ensuring your active records reflect reality. You can browse active companies with verified data on the BounceWatch company directory.
Scheduling Ongoing Validation
A one-time cleanup is valuable, but data decays continuously. According to Salesforce, B2B data decays at a rate of approximately 2–3% per month. After a year without maintenance, nearly a third of your database is stale.
The solution is a two-tier validation schedule:
Tier 1: Quarterly Batch Validation
Run the full extract-enrich-compare-update pipeline every quarter. This catches gradual changes: employee count shifts, industry re-classifications, new funding rounds, and recently acquired companies.
Quarterly cadence is sufficient for most fields because the changes are incremental. A company that was in "Fintech" last quarter is almost certainly still in "Fintech" this quarter.
# Cron job or scheduled task — quarterly validation
# Run on 1st of Jan, Apr, Jul, Oct
from datetime import datetime
def should_run_quarterly():
today = datetime.now()
return today.day == 1 and today.month in [1, 4, 7, 10]
def quarterly_validation():
"""Full database validation — run quarterly."""
print(f"Starting quarterly validation: {datetime.now()}")
# 1. Export all active company records
companies = export_crm_companies(status='active')
# 2. Batch enrich all domains
enriched = batch_enrich(companies['domain'].unique().tolist())
# 3. Compare and flag
flags = compare_all(companies, enriched)
# 4. Auto-apply safe updates, queue conflicts for review
auto, manual = categorize_updates(flags)
apply_updates(auto, dry_run=False)
queue_for_review(manual)
# 5. Generate report
generate_validation_report(flags, auto, manual)
print(f"Quarterly validation complete: {datetime.now()}")
Tier 2: Signal-Triggered Updates for Key Accounts
For your top 500–1,000 accounts, quarterly isn't fast enough. You need to know immediately when something changes. This is where signal-triggered updates come in.
Instead of polling the API on a schedule, subscribe to change signals:
- Funding events — the company just raised a round, which changes valuation, employee projections, and buying power
- Leadership changes — new CTO or VP of Sales means your champion may be gone
- Layoffs or rapid hiring — employee count is shifting significantly
- Acquisition announcements — the company may no longer be an independent buyer
- Product launches or pivots — industry classification may need updating
This hybrid approach — quarterly batch for the full database, real-time signals for key accounts — gives you the best balance of cost efficiency and data freshness. We cover this pattern in detail in our guide on automatic CRM enrichment.
Tools and APIs for Data Validation
There are several tools that can power your company data validation workflow. Here's how they compare for different parts of the pipeline:
| Tool | Best For | Coverage | Pricing Model |
|---|---|---|---|
| BounceWatch API | Firmographic enrichment, signal tracking, startup data | Global, strong on startups + SMBs | Per-lookup, affordable tiers |
| Clearbit (now Breeze) | Firmographic enrichment, tech stack detection | Strong on US tech companies | Annual contract, credit-based |
| ZoomInfo | Contact data, org charts, intent data | Deep US coverage, weaker internationally | Enterprise pricing, annual contract |
| NeverBounce | Email validation (not company enrichment) | Email verification only | Per-email, volume discounts |
| Custom Scripts | Domain resolution checks, social scraping | Whatever you build | Engineering time |
For a complete data validation workflow, you'll likely combine multiple tools. A typical stack looks like:
- BounceWatch API for company firmographic data, funding history, and operating status
- NeverBounce or ZeroBounce for email address validation
- Custom DNS checks for domain resolution validation
- Your CRM's native API for reading and writing records
The key advantage of API-based tools over manual research is throughput. A data analyst can verify maybe 200 companies per day. An API can validate 200 companies per minute. At 50,000 records, that's the difference between 250 working days and a few hours.
As discussed on Dev.to and across the data engineering community, the trend is clearly moving toward API-first data quality — where validation happens automatically as part of your data infrastructure, not as a manual project that someone kicks off twice a year.
Measuring Success
After implementing your validation pipeline, track these metrics to measure impact:
- Field completeness rate — percentage of records with all critical fields filled (target: 90%+)
- Data freshness score — percentage of records validated within the last 90 days
- Duplicate rate — number of duplicate domains in the database (target: <1%)
- Defunct record rate — percentage of records flagged as inactive (should decrease over time)
- Sales rep feedback — qualitative reduction in "this company info is wrong" complaints
- Campaign performance — improved targeting accuracy should show up as higher response rates
Most teams see a 15–25% improvement in campaign response rates and a 30% reduction in wasted outreach within the first quarter after implementing automated validation. The ROI is hard to argue with.
Clean Your Company Data with the BounceWatch Enrichment API
Bad data is expensive, and it gets worse every month you ignore it. The workflow in this guide — extract, enrich, compare, update — can take your CRM from a liability to an asset in a single afternoon.
BounceWatch's enrichment API gives you fresh firmographic data, funding history, operating status, and growth signals for any company — all from a single domain lookup. Batch process your entire database, flag defunct companies, fill in missing fields, and set up ongoing validation that runs on autopilot.
Start building your enrichment pipeline →
Or explore the BounceWatch company database to see the quality of data available through our API. Your CRM deserves better than 30% decay rates and ghost records.