Company enrichment API: turning a domain into a usable record

A company enrichment API takes a domain you already have and returns what that company publishes about itself: what the site says it is, the role mailboxes, the social profiles, the contact page and the technologies it runs on. Below: the fields you get, the ones no API can honestly give you, the thirty-day cache, and a script that enriches a CSV of domains and verifies the addresses that come back.

What a company enrichment API reads, and where it reads it

You have a list of domains. It came from a CRM export, a conference attendee list, the website column of a business search, or an email column you stripped down to the part after the @. What you do not have is a way to contact any of them or a sense of what they are.

Enrichment from a domain closes that gap by reading the site the way a person would: the homepage first, then one hop to the contact page when the homepage gave no way in. Everything returned was published by the company itself, at a public URL. There is no login, no third-party dossier and no inference.

That constraint is the product. A record built from the company's own pages is defensible: you can point at the page it came from, and the company can change it. A record assembled from mixed sources cannot make either claim.

What a company enrichment API cannot know from a domain

Headcount, revenue, funding rounds, industry codes, the CEO's name. None of these are on most business websites, and a vendor that returns them for every domain is either buying a third-party dataset or estimating. Both can be right; neither can tell you which rows are which, which is the problem.

Three more limits worth knowing before you plan around the output. The crawler does not execute JavaScript, so a contact page rendered client-side yields nothing and the record comes back thin. A site that answers the first request with a 403 is reported unreachable and left alone rather than retried from a different address.

The status field is how you tell an empty result from a failed one. Filter on it before you conclude that a company has no email address, and treat the five values differently:

statusWhat happenedWhat to do with the row
okThe site answered and was parsedUse it. Empty emails here means the site publishes none
unreachableNo answer, a timeout, or a refusal such as a 403Retry once next week, then set it aside. Do not re-request in a loop
parkedA registrar or for-sale placeholderDrop it. The company is not at this domain
redirect_socialThe domain forwards to a Facebook or Instagram pageKeep the social URL, expect no email, and look for the phone on the place record instead
no_websiteNothing resolved to fetchCheck the input; this is usually a typo or a dead domain

POST /v1/domains/enrich: the request and the response

Up to twenty domains per call, one credit each, deduplicated so the same host twice costs one. The input is forgiving: acme.example, www.acme.example and https://www.acme.example/pricing all normalise to one host.

curl -X POST "https://api.locomint.io/v1/domains/enrich" \
  -H "X-API-Key: $LOCOMINT_KEY" -H "Content-Type: application/json" \
  -d '{"domains": ["acme.example", "https://www.other.example/pricing"]}'

Each row of data comes back in this shape:

{
  "domain": "acme.example",
  "website": "https://acme.example/",
  "status": "ok",
  "title": "Acme Roofing - Flat roofs and repairs",
  "description": "Family-run roofing contractor covering the north of the city.",
  "language": "en",
  "emails": ["info@acme.example", "quotes@acme.example"],
  "socials": {"facebook": "https://facebook.com/acmeroofing"},
  "whatsapp": "+441234567890",
  "contact_form_url": "https://acme.example/contact",
  "tech_stack": ["WordPress", "Google Analytics", "Cloudflare"],
  "crawled_at": "2026-09-10T08:41:02Z"
}
FieldWhat it holds
domain, websiteThe normalised host, and the URL that was read
statusOne of the five values in the table above
title, description, languageWhat the site says it is, in its own words
emailsRole mailboxes on the company domain only
socials, whatsapp, contact_form_urlThe other ways in that the site publishes
tech_stackTechnologies detected by signature: CMS, analytics, chat widgets, hosting
crawled_atWhen the site was last read; the age of everything above it

For a single domain, GET /v1/domains/{domain} returns the same record without a request body. The full field list is in the enrichment reference and on the Company Enrichment API page.

tech_stack deserves a warning. It is detection by signature — a script tag, a header, a known path — across roughly thirty-five technologies. Present means present. Absent means we did not find a signature on the pages we read, not that the technology is missing.

Why the thirty-day cache is the right default

Records are cached per host for thirty days. A second call for the same domain inside that window returns the stored record, still for one credit, with crawled_at telling you when the site was actually read.

Thirty days matches how fast this data changes. A company's contact email, social links and CMS are stable for months; the parts that move weekly — opening hours, ratings — are not in this endpoint at all, they are in the place record. Caching more aggressively would return stale contacts; caching less would mean re-reading thousands of sites that said the same thing yesterday.

Pass "refresh": true when you have a reason: a bounce that suggests the mailbox changed, a redesign, or a record older than a quarter that you are about to act on. Refresh re-reads the site immediately and updates crawled_at.

Store crawled_at in your own table. It is the difference between "we have no email for this company" and "we had no email for this company five weeks ago". A nightly job that refreshes only rows where status != "ok" and crawled_at is older than thirty days fixes most of a stale list for a few credits, instead of re-enriching everything.

Why person-named mailboxes are dropped, on purpose

If a site publishes info@acme.example and maria.silva@acme.example, only the first one comes back. That is not a gap in the extractor. Person-named mailboxes and unknown off-domain addresses are dropped before the response is built, and never reach the cache.

A named mailbox identifies an individual, which makes it personal data under GDPR and comparable regimes, with a different legal basis, a different retention story and a subject-access obligation attached to it. A role mailbox is a contact point the business published so that strangers would write to it. Those are two different things, and mixing them into one column is how a lead list becomes a liability.

There is a practical argument too. Role mailboxes outlive the people behind them: sales@ still works after the salesperson leaves, while their personal address bounces. If you are building a list you intend to use for more than a quarter, the addresses we keep are the ones that survive. The wider question of what is and is not lawful to collect is covered in the article on whether web scraping is legal; this is not legal advice.

Enriching a CSV of domains in Python

The script below reads a CSV with a domain column, enriches in batches of twenty, and writes a flat CSV you can open in a spreadsheet. It handles the two things that break naive versions: the twenty-domain limit, and a 402 when the key runs out of credits mid-file.

import csv
import httpx

API = "https://api.locomint.io/v1"
HEADERS = {"X-API-Key": "lm_free_your_key_here"}
BATCH = 20

COLUMNS = [
    "domain", "status", "title", "description", "language",
    "emails", "whatsapp", "contact_form_url", "tech_stack", "crawled_at",
]


def batches(rows, size):
    for i in range(0, len(rows), size):
        yield rows[i:i + size]


def main(src="domains.csv", dst="enriched.csv"):
    with open(src, newline="", encoding="utf-8") as fh:
        domains = [r["domain"].strip() for r in csv.DictReader(fh) if r.get("domain")]

    seen, unique = set(), []
    for d in domains:
        if d.lower() not in seen:
            seen.add(d.lower())
            unique.append(d)

    with httpx.Client(base_url=API, headers=HEADERS, timeout=120) as client, \
            open(dst, "w", newline="", encoding="utf-8") as out:
        writer = csv.DictWriter(out, fieldnames=COLUMNS, extrasaction="ignore")
        writer.writeheader()

        for chunk in batches(unique, BATCH):
            response = client.post("/domains/enrich", json={"domains": chunk})
            if response.status_code == 402:
                print("Out of credits; stopping with the rows written so far.")
                break
            response.raise_for_status()
            for row in response.json()["data"]:
                writer.writerow({
                    **row,
                    "emails": ";".join(row.get("emails") or []),
                    "tech_stack": ";".join(row.get("tech_stack") or []),
                })
            print(f"{len(chunk)} domains done")


if __name__ == "__main__":
    main()

One hundred domains is a hundred credits and five sequential calls. Inside each call the API fetches five sites at a time and gives the whole call ninety seconds, after which it answers 504 and charges nothing. A batch of twenty slow or unreachable sites is the shape that hits that ceiling; if you see a 504, send ten domains per batch rather than twenty and the same work finishes.

Verifying the addresses before you use them

An extracted address is an address that appeared on a page. It is not proof that a mailbox exists: sites keep old contact pages, and a mailbox can be closed without the page being updated. POST /v1/emails/verify takes up to 100 addresses per call, one credit each, and never sends a message.

emails = [e for row in enriched for e in (row.get("emails") or [])]

with httpx.Client(base_url=API, headers=HEADERS, timeout=120) as client:
    checked = client.post("/emails/verify", json={"emails": emails[:100]}).json()["data"]

usable = [r["email"] for r in checked if r["status"] == "deliverable"]

Each row carries a statusdeliverable, undeliverable, risky for catch-all domains, disposable, invalid, unknown or valid when the mail server could not be asked — plus a reason, a 0–100 score and the individual checks: syntax_valid, mx_found, mx_host, role_account, catch_all, smtp_checked, smtp_code. risky is not a rejection; a catch-all domain accepts everything, so the server cannot tell you more. The Email Verification API page explains the states, and the article on why bounces cost you covers what to do with each one.

Order the two steps deliberately: enrich first, verify second, and only for addresses you intend to write to. Verifying everything doubles the cost of a list you have not qualified.

Choosing between b2b data enrichment vendors

Field counts are the wrong comparison; every vendor can add a column. Ask each one where the record came from, whether they will show you the URL, and what happens to person-named addresses. Ask how old a cached record can be before it is re-read, and whether that timestamp is in the response. Ask what a failed fetch returns — a status, or an empty object that looks like an answer.

Then compare prices per record on the volume you actually have, not the headline tier, and check the vendor's own pricing page. Locomint charges one credit per domain with the plans listed on the pricing page; there is no separate enrichment add-on.

Where to start this week

Take twenty domains you know well — customers, suppliers, competitors — and enrich them. Twenty credits, inside the free plan. Then check three things by hand: does the title describe the company as you would describe it, are the emails addresses you would actually write to, and does status explain every row that came back empty.

If more than a couple of rows are unreachable, look at those sites in a browser before blaming the API; small business sites go down more often than people expect. Once the sample looks right, run the CSV script over the real list and verify only the addresses on the rows you plan to contact.

If the lookups belong inside a conversation rather than a batch job, the same record is available to an assistant as the company_by_domain tool on the MCP server, at the same one credit per domain.

Frequently asked questions

What can a company enrichment API get from just a domain?

Whatever the company publishes on its own site: the title and description it wrote about itself, the page language, role email addresses, social profiles, a WhatsApp number when there is a click-to-chat link, the contact page URL, and the technologies detected by signature. It cannot see employee counts, revenue or funding, because a website does not carry them and inferring them would mean guessing.

Why are person-named email addresses dropped from the results?

A named mailbox identifies a person, which makes it personal data under GDPR and similar regimes, with a different legal basis from a company contact point. Locomint drops person-named and unknown off-domain addresses at extraction, so they never reach the response or the cache. Role mailboxes such as info@ or sales@ are business contact points and are kept.

How long is an enriched company record cached?

Thirty days per host. Asking for the same domain again inside that window returns the stored record and still costs one credit, and crawled_at tells you when the site was actually read. Pass refresh: true to re-read the site immediately. Duplicate domains inside one call are deduplicated and charged once.