# Enrich companies (https://webmetic.de/docs/en/api/anleitungen/unternehmen-anreichern)

With this guide, you add data to companies from your customer relationship management (CRM) system through the API. At the end you'll have:

- an Enrichment list with one row per domain, each finished as found or not found
- industry, employee count, revenue, address, phone number, and more data as JSON or CSV
- a script that you run again for the next domains
- the same list for your team in the **Enrichment** tab

## Before you start [#before-you-start]

- **Time**: Setting up the script takes a few minutes. How long the enrichment takes is described in [Wait for the result](#wait-for-the-result).
- **Account**: You need a Webmetic account that can use Enrichment. If your account can't use Enrichment right now, for example after a cancellation, the API answers with `403` and `ACCOUNT_NOT_ELIGIBLE`.
- **Access**: You need the **Enrichment** key, not the Visitor Intelligence key. With the wrong key, the API answers with `403` and `ENRICHMENT_KEY_NOT_FOUND`. You also need cURL or an environment for JavaScript or Python with `requests`.
- **Rights**: An admin generates the key under [**Menu** > **Integrations** > **API**](https://app.webmetic.de/?menu=api_details) with **Generate key** in the **Enrichment** card. The calls themselves only need the key.
- **Plan**: You can generate the key, create lists, and add domains only during the trial, on Growth, and on Enterprise. Enrichment currently costs no credits.

If a key already exists, don't click **Regenerate**, because after that the old key no longer works.

## Create a list [#create-a-list]

Create the list with [`POST /enrichment/lists`](https://webmetic.de/docs/en/api/referenz/enrichment/lists-post). The body needs a `name` with 1 to 200 characters, and a description in `desc` is optional.

The API answers with `201` and the `list` object. Keep `list.id`, because you need this ID for every further call.

Names don't have to be unique, so every call you make creates a new list, even with the same name.

## Add domains [#add-domains]

Send the domains with [`POST /enrichment/lists/{list_id}/domains`](https://webmetic.de/docs/en/api/referenz/enrichment/lists-list-id-domains) in the `domains` field. You can send 1 to 5,000 entries per request. For more, send further requests to the same list.

Webmetic shortens each entry to the domain. For example, `https://www.your-domain.com/contact` and `info@your-domain.com` both become `your-domain.com`, so you don't have to clean up the entries first.

The API answers with `202` and the counters `added`, `skipped_existing`, and `invalid`, but without row IDs. What the counters mean is described in [Duplicate and invalid entries](#duplicate-and-invalid-entries).

Webmetic then enriches the new rows in the background, without you starting anything.

## Wait for the result [#wait-for-the-result]

Call [`GET /enrichment/lists/{list_id}`](https://webmetic.de/docs/en/api/referenz/enrichment/lists-list-id) until `pending_rows` is 0. The list has no status of its own, and `pending_rows` counts every row that isn't `done` or `miss` yet.

Companies that Webmetic already knows are usually finished after about a minute, and large lists take longer. If Webmetic first has to read a website, it takes hours to a few days.

Because every row ends as `done` or `miss`, `pending_rows` always reaches 0. Still, give your script its own maximum wait so you don't wait for days.

Read the rows only once `pending_rows` is 0, because before that they can be half filled.

The status calls you make cost no credits, and [webhooks](https://webmetic.de/docs/en/api/webhooks) don't tell you when a list is finished. For details, see [Asynchronous results](https://webmetic.de/docs/en/api/asynchrone-ergebnisse).

## Read the rows [#read-the-rows]

Call [`GET /enrichment/lists/{list_id}/rows`](https://webmetic.de/docs/en/api/referenz/enrichment/lists-list-id-rows) with `status=done`. This way you only get the rows for which Webmetic found data.

Start with `page=1` and raise `page` until `isLast` in the `pagination` block is `true`. You get up to 10,000 rows per page, which is also the default. For details, see [Pagination](https://webmetic.de/docs/en/api/paginierung).

Every row contains all fields, and unknown values are `null`. Ranges such as `201–500`, industries, and descriptions are in German. You'll find every field in [Enrichment list](https://webmetic.de/docs/en/api/datenmodell/enrichment-liste).

Alternatively, you download all rows at once with [`GET /enrichment/lists/{list_id}/export`](https://webmetic.de/docs/en/api/referenz/enrichment/lists-list-id-export), as a CSV file with German column names. The file has no row ID, and the `Domain` column contains `company_url`.

## Write the data back to your CRM [#write-the-data-back-to-your-crm]

Webmetic doesn't write Enrichment data into your CRM, because the CRM integrations only transfer companies that Webmetic identifies on your website. So you write the data back with your own code.

Match the rows to your records by domain:

1. Before sending, shorten each record's website to the domain and keep the mapping.
2. For every row with `done`, look up the records with the same `domain`.
3. Write only fields that aren't `null`, so that existing values in your CRM stay as they are.
4. Check rows without a matching record by hand.

If a website redirects to a domain that Webmetic already knows, `domain` then holds that domain, and your matching doesn't find the row. `company_url`, the address where the website answers, helps you check it.

Don't write back rows with `miss`, because their name is only a placeholder made from the domain.

Enrichment rows have no `company_id`. You join them with data from the [daily visit export](https://webmetic.de/docs/en/api/anleitungen/besuche-taeglich-exportieren) by domain as well. To do so, shorten `company_url` from the visit data to the domain without `www.`.

## Complete script [#complete-script]

The script connects all the steps. Replace `crm` with the records from your CRM and `write_to_crm` (Python) or `writeToCrm` (JavaScript) with the call to your CRM. The cURL tab shows the individual calls.

The `to_domain` function shortens more roughly than Webmetic and keeps subdomains. If your CRM holds a website such as `shop.your-domain.com`, the row therefore ends up in the manual check, even though Webmetic found data.

The JavaScript uses `await` outside of functions. So run it as an ES module, for example as a file ending in `.mjs`.

You choose the polling interval and the maximum wait yourself. If the list isn't finished by then, the script stops and prints the list ID. Put the ID in `LIST_ID`, and the script keeps waiting when you run it again.

On an error, the script prints the status and the code, for example `PLAN_REQUIRED`. You'll find all codes in [Errors](https://webmetic.de/docs/en/api/fehler).

```bash tab="cURL" tab-group="lang"
# 1. Create the list
curl -X POST "https://hub.webmetic.de/enrichment/lists" \
  -H "Authorization: wmtc_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "CRM 2026-10"}'

# 2. Add domains, up to 5,000 per request
curl -X POST "https://hub.webmetic.de/enrichment/lists/YOUR_LIST_ID/domains" \
  -H "Authorization: wmtc_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"domains": ["https://www.bergquell.example/kontakt", "info@nordlicht-logistik.example"]}'

# 3. Repeat until pending_rows is 0
curl "https://hub.webmetic.de/enrichment/lists/YOUR_LIST_ID" \
  -H "Authorization: wmtc_YOUR_API_KEY"

# 4. Read the found rows, raise page until isLast is true
curl "https://hub.webmetic.de/enrichment/lists/YOUR_LIST_ID/rows?status=done&page=1&page_size=1000" \
  -H "Authorization: wmtc_YOUR_API_KEY"

# Or: every row of the list as one CSV file
curl "https://hub.webmetic.de/enrichment/lists/YOUR_LIST_ID/export" \
  -H "Authorization: wmtc_YOUR_API_KEY" \
  -o enrichment.csv
```

```js tab="JavaScript" tab-group="lang"
const BASE = 'https://hub.webmetic.de/enrichment';
const HEADERS = {
  Authorization: 'wmtc_YOUR_API_KEY', // Enrichment key
  'Content-Type': 'application/json',
};
const POLL_MS = 30 * 1000; // your own choice
const MAX_WAIT_MS = 2 * 60 * 60 * 1000; // your own choice
let LIST_ID = null; // set an existing list ID to keep waiting

// CRM records: record ID -> website or email as stored in your CRM
const crm = {
  'crm-1001': 'https://www.bergquell.example/kontakt',
  'crm-1002': 'info@nordlicht-logistik.example',
};

async function call(method, path, body) {
  const res = await fetch(BASE + path, {
    method,
    headers: HEADERS,
    body: body ? JSON.stringify(body) : undefined,
  });
  const text = await res.text();
  if (res.ok) return JSON.parse(text);
  let message = text.slice(0, 200); // not JSON, for example a proxy error page
  try {
    const { detail } = JSON.parse(text);
    message = `${detail.error}: ${detail.message}`;
  } catch {}
  throw new Error(`${res.status} ${message}`);
}

function toDomain(value) {
  // rough version of Webmetic's shortening, subdomains stay
  const host = value.trim().toLowerCase().split('@').pop().split('://').pop();
  return host.split(/[/?:]/)[0].replace(/^www\./, '');
}

async function writeToCrm(recordId, fields) {
  console.log(recordId, fields); // replace with your CRM's update call
}

const byDomain = {};
for (const [recordId, website] of Object.entries(crm)) {
  (byDomain[toDomain(website)] ??= []).push(recordId);
}

if (!LIST_ID) {
  // 1. Create the list
  LIST_ID = (await call('POST', '/lists', { name: 'CRM 2026-10' })).list.id;
  // 2. Add domains, up to 5,000 per request
  const domains = Object.keys(byDomain);
  for (let i = 0; i < domains.length; i += 5000) {
    console.log(await call('POST', `/lists/${LIST_ID}/domains`, { domains: domains.slice(i, i + 5000) }));
  }
}

// 3. Wait until no row is pending
const deadline = Date.now() + MAX_WAIT_MS;
while ((await call('GET', `/lists/${LIST_ID}`)).list.pending_rows > 0) {
  if (Date.now() > deadline) throw new Error(`Still running: set LIST_ID = '${LIST_ID}' and run again later`);
  await new Promise((resolve) => setTimeout(resolve, POLL_MS));
}

// 4. Read the found rows page by page
const rows = [];
for (let page = 1; ; page += 1) {
  const body = await call('GET', `/lists/${LIST_ID}/rows?status=done&page=${page}&page_size=1000`);
  rows.push(...body.rows);
  if (body.pagination[0].isLast) break;
}

// 5. Write back known values only
const ROW_FIELDS = ['id', 'domain', 'status', 'src', 'created_at', 'enriched_at'];
for (const row of rows) {
  const recordIds = byDomain[row.domain];
  if (!recordIds) {
    console.log('Check by hand:', row.domain, row.company_url);
    continue;
  }
  const fields = Object.fromEntries(
    Object.entries(row).filter(([key, value]) => value !== null && !ROW_FIELDS.includes(key)),
  );
  for (const recordId of recordIds) await writeToCrm(recordId, fields);
}
```

```python tab="Python" tab-group="lang"
import re
import time
import requests

BASE = "https://hub.webmetic.de/enrichment"
HEADERS = {"Authorization": "wmtc_YOUR_API_KEY"}  # Enrichment key
POLL_SECONDS = 30  # your own choice
MAX_WAIT_SECONDS = 2 * 60 * 60  # your own choice
LIST_ID = None  # set an existing list ID to keep waiting

# CRM records: record ID -> website or email as stored in your CRM
crm = {
    "crm-1001": "https://www.bergquell.example/kontakt",
    "crm-1002": "info@nordlicht-logistik.example",
}


def call(method, path, **kwargs):
    res = requests.request(method, BASE + path, headers=HEADERS, timeout=60, **kwargs)
    if res.status_code >= 400:
        try:
            detail = res.json()["detail"]
            message = f"{detail['error']}: {detail['message']}"
        except (ValueError, KeyError, TypeError):
            message = res.text[:200]  # not JSON, for example a proxy error page
        raise SystemExit(f"{res.status_code} {message}")
    return res.json()


def to_domain(value):
    # rough version of Webmetic's shortening, subdomains stay
    host = value.strip().lower().split("@")[-1].split("://")[-1]
    host = re.split(r"[/?:]", host)[0]
    return host[4:] if host.startswith("www.") else host


def write_to_crm(record_id, fields):
    print(record_id, fields)  # replace with your CRM's update call


by_domain = {}
for record_id, website in crm.items():
    by_domain.setdefault(to_domain(website), []).append(record_id)

if LIST_ID is None:
    # 1. Create the list
    LIST_ID = call("POST", "/lists", json={"name": "CRM 2026-10"})["list"]["id"]
    # 2. Add domains, up to 5,000 per request
    domains = list(by_domain)
    for i in range(0, len(domains), 5000):
        print(call("POST", f"/lists/{LIST_ID}/domains", json={"domains": domains[i:i + 5000]}))

# 3. Wait until no row is pending
deadline = time.monotonic() + MAX_WAIT_SECONDS
while call("GET", f"/lists/{LIST_ID}")["list"]["pending_rows"] > 0:
    if time.monotonic() > deadline:
        raise SystemExit(f"Still running: set LIST_ID = '{LIST_ID}' and run again later")
    time.sleep(POLL_SECONDS)

# 4. Read the found rows page by page
rows, page = [], 1
while True:
    params = {"status": "done", "page": page, "page_size": 1000}
    body = call("GET", f"/lists/{LIST_ID}/rows", params=params)
    rows += body["rows"]
    if body["pagination"][0]["isLast"]:
        break
    page += 1

# 5. Write back known values only
ROW_FIELDS = {"id", "domain", "status", "src", "created_at", "enriched_at"}
for row in rows:
    record_ids = by_domain.get(row["domain"])
    if not record_ids:
        print("Check by hand:", row["domain"], row["company_url"])
        continue
    fields = {k: v for k, v in row.items() if v is not None and k not in ROW_FIELDS}
    for record_id in record_ids:
        write_to_crm(record_id, fields)
```

The domains in the example are invented. The API counts entries ending in `.example` as `invalid`.

## Duplicate and invalid entries [#duplicate-and-invalid-entries]

The counters in the response and the status of the rows show what happened to your entries:

- **Duplicate in one request**: Webmetic creates the domain once and doesn't count the repeats. So the three counters don't always add up to the number of entries you sent.
- **Already in the list**: `skipped_existing` counts domains that are already in this list. Webmetic doesn't create a second row. In another list, the same domain gets its own row.
- **Invalid**: `invalid` counts entries without a readable domain, for example a company name or an IP address. The API doesn't say which entries they were. If you need to know, shorten your entries yourself before sending, like `to_domain` in the script, and check which ones contain no domain.
- **Several domains in one entry**: Webmetic only reads the last one. Send each domain as its own entry.
- **Subdomain of a platform**: Webmetic shortens it to the platform's domain. So enter the company's own domain.
- **Not found**: With `miss`, Webmetic found no usable data.
- **Two rows with the same domain**: If two domains redirect to the same company, the list can contain two such rows.
- **Enrich again**: `done` and `miss` are final, and Webmetic doesn't update finished rows. For fresh data, create a new list.

You can delete lists and remove rows only in the dashboard. For details, see [Results and export](https://webmetic.de/docs/en/enrichment/ergebnisse-und-export).

## Next steps [#next-steps]

- [Enrichment list](https://webmetic.de/docs/en/api/datenmodell/enrichment-liste): Every field of a row with type, example, and meaning.
- [Results and export](https://webmetic.de/docs/en/enrichment/ergebnisse-und-export): Check, correct, and export the same list in the dashboard.
- [Errors](https://webmetic.de/docs/en/api/fehler): Understand and fix error codes such as `PLAN_REQUIRED`.
