Enrich a CRM List¶
Take a list of companies your team already knows about — exported from Salesforce, HubSpot, or a spreadsheet — and stamp each row with the canonical name, status, address, NACE code, and source URL from the official registry.
This guide walks through the safe pattern: estimate → prepare → batch → handle → retry.
1. Estimate cost first¶
Before processing more than ~10 rows, check what the job will cost and how much of your monthly quota it will eat.
For agents, prefer the dedicated firmadb_estimate_cost MCP tool — it returns a min/max cost range and a human_approval_recommended flag (true when estimated spend exceeds €10 or 50% of remaining quota). See Cost Estimation.
404s are free, matches are billed
A row that returns 404 (company_not_found) does not consume a billable result. A row that returns a full record consumes exactly one. Plan from the upper bound (estimated_records), not the lower.
2. Prepare the input¶
Each row needs two things: an ISO-3166-1 alpha-2 country code, and the country's exact registry ID format.
| Country | Format | Example | Common mistake |
|---|---|---|---|
| FR | 9-digit SIREN | 552120222 |
Mixing up SIREN (9) with SIRET (14) |
| GB | 8-char CRN with leading zeros | 00445790 |
Stripping leading zeros to 445790 |
| BE | Dotted VAT (0xxx.xxx.xxx) |
0403.227.515 |
Removing the dots |
| SE | 10-digit organisationsnummer | 5560295593 |
Using the personnummer hyphen |
| NO | 9-digit organisasjonsnummer | 971526157 |
None — clean format |
| CZ | 8-digit IČO | 00006947 |
Stripping leading zeros |
Always send registry_id as a string
JSON parses "00006947" as "00006947" but 00006947 (no quotes) as the integer 6947. The registry ID changes meaning. Always quote it. Always preserve leading zeros.
A clean batch payload:
{
"references": [
{ "country": "FR", "registry_id": "552120222" },
{ "country": "GB", "registry_id": "00445790" },
{ "country": "BE", "registry_id": "0403.227.515" }
],
"idempotency_key": "550e8400-e29b-41d4-a716-446655440000"
}
3. Call the batch endpoint¶
POST /v1/companies/lookup-batch accepts up to 100 rows per call and requires an idempotency_key (any UUID). For lists larger than 100, chunk client-side and use a fresh UUID per chunk.
curl -X POST "https://api.firmadb.com/v1/companies/lookup-batch" \
-H "Authorization: Bearer $FIRMADB_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"references": [
{"country":"FR","registry_id":"552120222"},
{"country":"GB","registry_id":"00445790"}
],
"idempotency_key": "550e8400-e29b-41d4-a716-446655440000"
}'
import httpx, os, uuid
payload = {
"references": [
{"country": "FR", "registry_id": "552120222"},
{"country": "GB", "registry_id": "00445790"},
],
"idempotency_key": str(uuid.uuid4()),
}
resp = httpx.post(
"https://api.firmadb.com/v1/companies/lookup-batch",
json=payload,
headers={"Authorization": f"Bearer {os.environ['FIRMADB_API_KEY']}"},
timeout=60,
)
results = resp.json()["results"]
import { randomUUID } from "node:crypto";
const resp = await fetch(
"https://api.firmadb.com/v1/companies/lookup-batch",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.FIRMADB_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
references: [
{ country: "FR", registry_id: "552120222" },
{ country: "GB", registry_id: "00445790" },
],
idempotency_key: randomUUID(),
}),
}
);
const { results } = await resp.json();
4. Handle per-row results¶
The response contains one entry per input row, in the same order. Each entry has a match_status and either a company object or an embedded RFC 9457 error.
{
"results": [
{
"index": 0,
"match_status": "found",
"company": {
"country": "FR",
"registry_id": "552120222",
"name": "SOCIETE GENERALE",
"status": "active",
"source_url": "https://annuaire-entreprises.data.gouv.fr/etablissement/55212022200013",
"data_freshness": { "last_confirmed_at": "2026-05-04T03:14:22Z" }
}
},
{
"index": 1,
"match_status": "not_found",
"error": {
"type": "https://errors.firmadb.com/company-not-found",
"code": "company_not_found",
"status": 404,
"correction": "Try searching by name with /v1/companies/search?country=GB&q=..."
}
}
],
"billable_results": 1
}
Branch on match_status, never on the prose in title/detail:
match_status |
What happened | Billable? |
|---|---|---|
found |
Record returned | Yes (1) |
not_found |
Well-formed ID, no record | No |
invalid |
Bad ID format — read error.correction |
No |
error |
Transient failure — safe to retry | No |
5. Retry safely¶
The idempotency contract gives you a free safety net for network errors:
- Same key + same body within 24h → cached response, no re-billing.
- Same key + different body →
409 idempotency_conflict, generate a new UUID. - After 24h → key expires and may be reused.
def submit_chunk(chunk, key):
for attempt in range(3):
try:
r = httpx.post(URL, json={"references": chunk, "idempotency_key": key},
headers=HEADERS, timeout=60)
r.raise_for_status()
return r.json()
except (httpx.TimeoutException, httpx.HTTPStatusError) as e:
if attempt == 2 or (hasattr(e, "response") and e.response.status_code < 500):
raise
time.sleep(2 ** attempt)
The key here doesn't change between attempts. That's the point — if attempt 1 silently succeeded server-side but the response was lost in transit, attempt 2 returns the same cached payload instead of running (and billing) the batch a second time.
End-to-end skeleton¶
import httpx, os, uuid, csv
API = "https://api.firmadb.com/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['FIRMADB_API_KEY']}"}
def chunked(rows, size=100):
for i in range(0, len(rows), size):
yield rows[i:i + size]
with open("crm.csv") as f:
rows = [{"country": r["country"], "registry_id": r["id"]}
for r in csv.DictReader(f)]
enriched = []
for chunk in chunked(rows):
resp = httpx.post(
f"{API}/companies/lookup-batch",
json={"references": chunk, "idempotency_key": str(uuid.uuid4())},
headers=HEADERS, timeout=60,
)
enriched.extend(resp.json()["results"])
print(f"Enriched {sum(1 for r in enriched if r['match_status'] == 'found')} of {len(rows)} rows")
What's next¶
- Just need a status check? → KYB Status Verification
- Worried about hitting rate limits? → Rate Limits & Caching
- Full error catalog → Errors