Skip to content

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.

curl "https://api.firmadb.com/v1/account/usage" \
  -H "Authorization: Bearer $FIRMADB_API_KEY"
import httpx, os
usage = httpx.get(
    "https://api.firmadb.com/v1/account/usage",
    headers={"Authorization": f"Bearer {os.environ['FIRMADB_API_KEY']}"},
).json()
print(usage["results_remaining"], "results left this period")
const usage = await fetch(
  "https://api.firmadb.com/v1/account/usage",
  { headers: { Authorization: `Bearer ${process.env.FIRMADB_API_KEY}` } }
).then(r => r.json());
console.log(`${usage.results_remaining} results left this period`);

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 body409 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