# API reference — Company Contact Finder: Name to Website, Emails and Phones

## One dataset item (sample output)

Nordhafen Textilhandel GmbH is a fictional, labelled sample record — sample output of a local run of
`storage-example/INPUT.ai.json`, 2026-10-23, AI on with an own Gemini key (`google:gemini-3.5-flash-lite`, the own-key
default). With **Add named people's e-mails** on, Jana Petersen would get `j.petersen@…` (`emailSource:
"pattern-derived"`, `pattern: "f.last (from m.brandt@…)"`); with **Check the mailbox itself** on, `mailboxStatus` holds
the SMTP result.

```json
{
  "itemId": "f0ef1f6fc77a",
  "name": "Nordhafen Textilhandel GmbH",
  "url": "https://www.nordhafen-textilhandel.de/",
  "inputName": "Nordhafen Textilhandel GmbH",
  "inputCountry": "DE",
  "website": "https://www.nordhafen-textilhandel.de/",
  "domain": "nordhafen-textilhandel.de",
  "websiteConfidence": 0.86,
  "websiteEvidence": "domain spells the name (1.00); .de = DE; legal name 'Nordhafen Textilhandel GmbH' on the site; VAT DE000000001 (DE) on the page; Google result #1; skipped europages.de, linkedin.com (directory / marketplace / social)",
  "legalName": "Nordhafen Textilhandel GmbH",
  "vatId": "DE000000001",
  "address": "Am Sandtorkai 12, 20457 Hamburg",
  "employeesHint": "rund 40 Mitarbeiter",
  "industry": null,
  "foundBy": null,
  "emails": [
    {
      "email": "einkauf@nordhafen-textilhandel.de",
      "role": "purchasing",
      "status": "ok",
      "mx": true,
      "disposable": false,
      "isRoleAccount": true,
      "typoSuggestion": null,
      "foundOn": "https://www.nordhafen-textilhandel.de/kontakt"
    },
    {
      "email": "m.brandt@nordhafen-textilhandel.de",
      "role": "person",
      "status": "ok",
      "mx": true,
      "disposable": false,
      "isRoleAccount": false,
      "typoSuggestion": null,
      "foundOn": "https://www.nordhafen-textilhandel.de/team"
    },
    {
      "email": "info@nordhafen-textilhandel.de",
      "role": "general",
      "status": "ok",
      "mx": true,
      "disposable": false,
      "isRoleAccount": true,
      "typoSuggestion": null,
      "foundOn": "https://www.nordhafen-textilhandel.de/impressum"
    }
  ],
  "bestEmail": "einkauf@nordhafen-textilhandel.de",
  "bestEmailRole": "purchasing",
  "bestEmailStatus": "ok",
  "bestEmailReason": "MX ok; purchasing inbox — your preferred role 1 of 4; on the company's own domain; found on /kontakt; 2 other checked addresses ranked lower",
  "contactRoute": "mailto:einkauf@nordhafen-textilhandel.de",
  "phones": [
    {
      "number": "+4940300000",
      "foundOn": "https://www.nordhafen-textilhandel.de/kontakt"
    }
  ],
  "contactPageUrl": "https://www.nordhafen-textilhandel.de/kontakt",
  "contactFormUrl": "https://www.nordhafen-textilhandel.de/kontakt",
  "socials": {
    "linkedin": "https://www.linkedin.com/company/nordhafen-textilhandel",
    "facebook": null,
    "instagram": null,
    "x": null,
    "youtube": null
  },
  "people": [
    {
      "name": "Markus Brandt",
      "title": "Leitung Einkauf",
      "email": "m.brandt@nordhafen-textilhandel.de"
    },
    {
      "name": "Jana Petersen",
      "title": "Vertrieb Export",
      "email": null
    }
  ],
  "sourcePath": "own",
  "notFound": false,
  "notFoundReason": null,
  "flags": [
    "purchasing_contact",
    "named_person",
    "own_domain_email",
    "phone_found",
    "contact_form",
    "linkedin_found"
  ],
  "targetFlagsMatched": [],
  "ruleScore": 97,
  "score": 97,
  "label": "hot",
  "fitScore": 97,
  "scoreReasons": [
    "Purchasing email found on the company's domain",
    "High website confidence with legal name match",
    "Named person in purchasing role available"
  ],
  "summary": "Nordhafen Textilhandel GmbH is a textile trading company importing cotton and linen fabrics, and their purchasing inbox should be used.",
  "generatedText": "Guten Tag Markus Brandt, ich habe gesehen, dass Sie Baumwoll- und Leinenstoffe importieren. Wir bieten hochwertige Bio-Baumwoll-Jerseys für EU-Textilimporteure an und würden uns über eine Zusammenarbeit freuen.",
  "recommendedNextStep": "Write to einkauf@nordhafen-textilhandel.de",
  "aiModel": "google:gemini-3.5-flash-lite",
  "assessedBy": "ai",
  "chargedEvent": "demo",
  "billedAs": "qualified-company-contact-ai",
  "dedupeKey": "domain:nordhafen-textilhandel.de",
  "source": {
    "mode": "list",
    "query": "Nordhafen Textilhandel GmbH"
  },
  "checkedAt": "2026-10-23T10:24:10+00:00"
}
```

## Every field of a row

| Field | Description |
|---|---|
| `itemId`, `dedupeKey`, `name`, `url` | identity; `url` = the chosen website (the Google search while none was found) |
| `inputName`, `inputCountry` | your input, unchanged (country as ISO code) |
| `website`, `domain`, `websiteConfidence`, `websiteEvidence`, `websiteOnly` | the chosen site, 0–1 confidence and why; `websiteOnly` = a **Website only** row |
| `legalName`, `vatId`, `address`, `employeesHint` | as printed on the imprint / about pages, `null` when not printed — never estimated |
| `industry` | AI tier: what the company does in 2–6 words, as its pages say it; `null` on the rule tier |
| `foundBy` | the keyword search (`search: textile importers, DE`) or directory page (`list page: <url>`) that found it; `null` for a typed name |
| `emails` | each `{email, role, status, mx, disposable, isRoleAccount, typoSuggestion, foundOn}` |
| `bestEmail`, `bestEmailRole`, `bestEmailStatus`, `bestEmailReason` | the address to write to |
| `mailboxStatus`, `mailboxReason` | the SMTP check of `bestEmail` (valid · invalid · catch_all · risky · unknown), with **Check the mailbox itself** |
| `contactRoute` | mailto → tel → contact form → LinkedIn → contact page → website |
| `phones`, `contactPageUrl`, `contactFormUrl`, `socials` | `{number, foundOn}`; `{linkedin, facebook, instagram, x, youtube}` |
| `people` | AI tier: `{name, title, email, emailSource, pattern}` — `printed`, or `pattern-derived` with the colleague's address it came from |
| `sourcePath` | `own` · `store-search` · `store-contacts` (a fallback ran on your account) |
| `notFound`, `notFoundReason` | free rows: `no_website` · `site_unreachable` · `no_contact` · `only_invalid` |
| `flags`, `targetFlagsMatched`, `ruleScore`, `score`, `label`, `fitScore`, `assessedBy` | scoring |
| `scoreReasons`, `summary`, `generatedText`, `recommendedNextStep`, `aiModel` | the AI check, or the rule line |
| `chargedEvent`, `billedAs`, `source`, `checkedAt` | billing, provenance, when this run checked it |

## Tested combinations

Each input below is run by the test suite (`tests/`) or the local end-to-end gates before every build; the JSON is
what you send. Other combinations of the 50 declared inputs work but are not pinned by a test.

| Call | Input JSON | Pinned by |
|---|---|---|
| Free demo | `{}` | local run with `{}` (Apify's automated test) |
| Live, names only | `{"sourceMode": "live", "companies": ["Salzgitter AG, DE"]}` | `storage-example/INPUT.live.json` probe, `tests/test_enrich.py` |
| Live, websites given | `{"sourceMode": "live", "companies": ["Hilti AG, LI, hilti.com"]}` | `agency-crm-cleanup` preset, `tests/test_enrich.py` |
| Keyword search | `{"sourceMode": "live", "searchQuery": "textile importers, DE", "maxSearchResults": 10}` | `tests/test_review_2026_10_23_4.py` |
| Directory page | `{"sourceMode": "live", "listPageUrl": "https://www.vdma.org/mitglieder"}` | `tests/test_review_2026_10_23_4.py` |
| File / Google Sheet | `{"sourceMode": "live", "companiesFileUrl": "https://docs.google.com/spreadsheets/d/<id>/edit"}` | `tests/test_review_2026_10_23.py` |
| Your rows | `{"sourceMode": "list", "itemsList": [{"name": "Salzgitter AG", "country": "DE", "website": "salzgitter-ag.com"}]}` | `tests/test_byod.py` |
| A dataset | `{"sourceMode": "dataset", "datasetId": "<datasetId>"}` | `tests/test_byod.py` |
| Website only | `{"sourceMode": "live", "companies": ["Salzgitter AG, DE"], "domainOnly": true}` | `tests/test_review_2026_10_23.py` |
| AI, own key | `{"sourceMode": "live", "companies": ["Salzgitter AG, DE"], "enableAi": true, "llmProvider": "byok", "llmApiKey": "<your Gemini key>"}` | `tests/test_llm_mode_switch.py`, `storage-example/INPUT.ai.json` |

## Adjacent tools and the free ones

| Tool | Where it wins | What it lacks |
|---|---|---|
| [datagleaner/website-contact-details-scraper](https://apify.com/datagleaner/website-contact-details-scraper) | $0.20 per 100 sites with an e-mail | needs websites; no MX check (its README) |
| [automation-lab/website-contact-finder](https://apify.com/automation-lab/website-contact-finder) | $0.10 per 100 pages + $0.035 start | needs websites (its input) |
| [delicious_zebu/contact-info-scraper](https://apify.com/delicious_zebu/contact-info-scraper) · [caprolok/website-email-phone-finder](https://apify.com/caprolok/website-email-phone-finder) | every row of a website list | need websites; $1–2 per 100 rows |
| [apify/google-search-scraper](https://apify.com/apify/google-search-scraper) (24,313 users) | any Google query, $0.0045 a page | no website choice, no contacts |
| [clearpath/email-finder-api](https://apify.com/clearpath/email-finder-api) | a person's e-mail from a name you already have | needs the person and the domain; 17 % of runs fail |
| [bounceverify/bounceverify-email-verifier](https://apify.com/bounceverify/bounceverify-email-verifier) | SMTP mailbox check, $0.00089 each | finds nothing — here it runs inside **Check the mailbox itself** |

## Which call runs what — `OUTPUT.mode`

The mode that runs = `sourceMode`, except `{}` = the free demo. `OUTPUT.mode` echoes the decided mode, and the first log
line says why.

| Input shape | `OUTPUT.mode` | Can it charge? |
|---|---|---|
| `{}` or the untouched form | `demo` | no |
| `sourceMode: "live"` + `companies`, `companiesFileUrl`, `searchQuery` and / or `listPageUrl` | `live` | yes, per company contact found |
| `sourceMode: "list"` + the untouched sample + your `companies`, over the API | `demo` | no — the status names the unsearched names |
| the same in the Console (the form's prefill) | `live` | yes — the typed names start the live search (Console only; off with `autoSwitchToLive: false`) |
| `sourceMode: "list"` + your `itemsList` | `list` | yes, per company contact found |
| `sourceMode: "dataset"` + `datasetId` | `dataset` | yes, per company contact found |
| `domainOnly: true` with any of the above | the same | `website-found` per website found ($0.002), no contacts |
| `discoveryInput` (raw input for `discoveryActorId`, API only) | `live` | yes — and the Store scraper bills your account |

## Run outcomes — what your integration sees

| Outcome | Run status | Dataset | `OUTPUT` | Charged? |
|---|---|---|---|---|
| Free demo (`{}`) | SUCCEEDED | 3 sample rows | `mode: demo`, `ok: true` | no |
| Success | SUCCEEDED | one row per company found | `ok: true`, `outcome: ok`, `contacts` | per company contact |
| Nothing found | SUCCEEDED | empty (or free `notFound` rows) | `ok: true`, `contacts.notFoundBy` | no — not by us, no source scan |
| Google or a site refused | SUCCEEDED | rows of the rest | `finder.searchBlocked`, `finder.siteBlocked`, `sourcePath` | per company contact; a fallback on your account, capped |
| Your plan cannot run the Store fallback | SUCCEEDED; check `OUTPUT.ok` | those companies not found; with a raw `discoveryInput`: Console — the free sample, schedule / API — an alert to `notifyEmail` | `finder.fallbackErrors`; `ok: false`, `outcome: source_refused`, `sourceRefused: plan` — paste rows in `list` mode | no |
| Companies file unreadable | FAILED, status "Companies file not read: …" | empty | `ok: false`, `sourceError` | no |
| Directory page unreadable / keyword search found nothing | FAILED, status "Directory page not read: …" / "Company search found nothing: …" | empty | `ok: false`, `sourceError` | no |
| Source failed (dataset unreadable) | FAILED | empty | `ok: false`, `outcome: source_failed`, `sourceError` | no |
| Source partial (a dataset run aborted) | SUCCEEDED | the rows read | `sourceStatus` (`partial: true`) | per company contact |
| AI unavailable | SUCCEEDED | rule rows | `aiError`, `aiCoverage` | basic price |
| Mailbox check refused (plan) | SUCCEEDED | rows without `mailboxStatus` | `mailboxCheck.error` | no `mailbox-verified` |
| HubSpot refused the token | SUCCEEDED | every row | `crm.failed`, `crm.error` | unchanged |
| Invalid input | FAILED | empty | — | no |
| Crashed | FAILED, status "Failed: <reason>" | rows delivered before it | `ok: false`, `outcome: error` | delivered rows only |

Branch on `OUTPUT.ok` / `OUTPUT.outcome` — never on an empty dataset. On a schedule send `failOnSourceError: true`.

## List-mode rows and file columns — what a record needs

The same names work as `itemsList` keys, dataset fields and the header row of `companiesFileUrl` (case, spaces, `_` and
`-` ignored; "Company Name", "Website URL", "Country/Region" of a CRM export work as they are).

| Field | Needed? | What it enables |
|---|---|---|
| `name` (or `companyName`, `company`, `consigneeName`, `shipperName`, `buyerName`, `title`) | required | the company to find |
| `country` (or `countryCode`) | recommended | the country TLD and namesakes |
| `website` (or `domain`, `url`) | optional | skips the Google search |
| `city` | optional | kept in the row |
| `emails`, `phones` | optional | checked and picked from, no crawl |

## Run time per mode, with the `timeoutSecs` to set

One formula: **`timeoutSecs` = 240 + 30 s × companies** — 50 companies → 1740, 100 → 3240. It covers the search
(≤ 7 s per company, 6 at a time) and the AI at a rate-limited own key's worst case (2 candidates per company, ≈ 30 s
per call, 2 at a time); at the measured own-key pace (≈ 1.0–11.2 s per company) a run ends far sooner.

| Mode | Measured | `timeoutSecs` |
|---|---|---|
| Free demo | 3–4 s on Apify (2026-10-23) | 120 |
| Live search, 5 real companies | 4–5 of 5 with a contact, 6.7–36 s on Apify (2026-10-23: websites given 5 of 5 in 6.7 s, names only 4 of 5 in 36 s) — ≤ 7 s per company, 6 at a time | 240 + 30 s × companies |
| Keyword search / directory page | + one Google page per 10 companies / one page read | the same |
| Website only (`domainOnly`) | one Google page per company, no page read | the same (ends far sooner) |
| AI, own key (`google:gemini-3.5-flash-lite`) | measured 2026-10-23: ≈ 1.0–11.2 s per company (11.2 s on the latest run) | the same |
| AI via Apify's model access (`anthropic/claude-haiku-4.5`) | not measured on the platform yet (dated 2026-10-30) | the same |
| Mailbox check (`verifyMailbox`) | one bounceverify run for all rows | + 600 |

A rate-limited own key stops the AI after 2 minutes of waiting, rule rows kept; a free key gives 1–3 AI verdicts per run.

## HubSpot push (`hubspotToken`)

A private-app token with `crm.objects.companies.write` and `crm.objects.contacts.write`. Per delivered row: the company
is searched by `domain` and updated (else created: `name` = `legalName`, `domain`, `website`, `phone`, `country`,
LinkedIn page), `bestEmail` is upserted as a contact by e-mail (`company`, `website`, `jobtitle` = "<role> inbox") and
associated to the company. `OUTPUT.crm` = `{crm, companies, contacts, failed, error}`; a failure never fails the run.
Google Sheets: Apify's built-in Google Sheets integration (Integrations tab) appends the dataset after every run;
a native Sheets append is dated in the ROADMAP.

## The `OUTPUT` record

`ok` and `outcome` (`ok` · `empty` · `source_refused` · `source_failed` · `error`) say how the run ended; branch on them,
never on an empty dataset. `RUN_SUMMARY` is the same record. Keys of a run (types as written by the run above; `null`
when the run had nothing to say):

| Key | Type |
|---|---|
| `aiCoverage` | object |
| `aiError` | null |
| `aiTokens` | object |
| `aiTrial` | null |
| `budgetLimitReached` | boolean |
| `candidates` | number |
| `chargeSplit` | null |
| `chargedEvents` | object |
| `contacts` | object |
| `crm` | object (HubSpot: companies, contacts, failed, error) or null |
| `dedupeStore` | null |
| `derivedPersonEmails` | number or null (`derivePersonEmails`) |
| `domainOnly` | true or null |
| `mailboxCheck` | object (`checked`, `byStatus`, `verifierCostUsd`, `error`) or null |
| `demo` | null |
| `demoIgnoredFields` | null |
| `demoRows` | number |
| `demoTextsDropped` | null |
| `demoTextsSearched` | null |
| `finder` | object |
| `firstRunBackfill` | null |
| `freeAiReserve` | null |
| `freeTierRemaining` | null |
| `freeTierThisRun` | number |
| `history` | null |
| `historyDataset` | null |
| `mode` | string |
| `nextSteps` | array |
| `notDeliveredCap` | null |
| `notFoundEmitted` | null |
| `ok` | boolean |
| `outcome` | string |
| `presetApplied` | null |
| `qualified` | number |
| `reportUrl` | null |
| `reviewAsked` | null |
| `sampleFallback` | null |
| `scanCapHit` | null |
| `scheduleInput` | object |
| `scheduledSizing` | null |
| `seedsRolled` | null |
| `sourceError` | null |
| `sourceRefused` | null |
| `sourceStatus` | null |
| `stats` | object |
| `switchedToLive` | null |
| `targetFlags` | null |
| `timeSaved` | string |
| `timing` | object |
| `trend` | null |
| `unchargedEvents` | null |
| `webhook` | null |
| `yieldByQuery` | object |

`contacts` = `{found, notFound, notFoundBy, byRole, mxFailures, ownDomain}` (the digest's counts); `finder` =
`{searched, searchBlocked, sitesRead, siteBlocked, storeSearch, storeContacts, closeCalls, dnsLookups, fallbackErrors}`;
`timing.findSecs` = the website + contacts + checks phase.

## Flags (`targetFlags`)

Type the value as written (`purchasingContact` works too). The flags come from the rules; how often a role flag is
right is not yet measured on a labelled sample (roadmap 2026-11-06).

| Flag | Meaning |
|---|---|
| `purchasing_contact` | a purchasing / procurement inbox is on the company's pages |
| `export_contact` | an export / import / international inbox is on the company's pages |
| `sales_contact` | a sales inbox is on the company's pages |
| `named_person` | a named person's address is on the company's pages |
| `own_domain_email` | the address to write to is on the company's own domain |
| `free_mail_only` | the address to write to is a free-mail address (gmail, yahoo …) |
| `role_only` | only generic role inboxes (info@, office@ …), no named person |
| `risky_email` | the address to write to has no MX record — mail may bounce |
| `phone_found` | a phone number is on the company's pages |
| `contact_form` | the site has a contact form |
| `linkedin_found` | the company's LinkedIn page is linked from its site |
| `website_doubtful` | the website choice is uncertain (confidence below 0.6) |
| `website_given` | the website came from your input |
| `typo_suspected` | an address on the pages looks like a domain typo |
| `store_fallback` | found through a Store Actor fallback, billed on your Apify account |
| `not_found` | no website or no usable address — a free notFound row |

## API-only inputs

Declared in the input schema (the API tab, OpenAPI and MCP see them), hidden on the Console form:

| Input | Default | What it does |
|---|---|---|
| `slackWebhookUrl`, `discordWebhookUrl`, `teamsWebhookUrl`, `googleChatWebhookUrl`, `telegramUrl` | — | one alert URL per service — the form takes them all in **Send alerts to** |
| `webhookHeaders`, `webhookBatchSize`, `webhookSecret` | `{}`, `1`, — | extra headers, one POST per N rows, the HMAC signing secret |
| `autoSwitchToLive` | `true` | Console only: typed names next to the untouched sample start the live search; ignored over the API |
| `failOnSourceError` | `false` | `true`: a source that delivered nothing ends FAILED, so a schedule's alert fires |
| `useStoreFallback`, `discoveryActorId` | `true`, `vdrmota/contact-info-scraper` | Google or a site refused: a Store Actor reads it on your account, capped by `maxDiscoveryChargeUsd` |
| `includeSocials`, `skipRoleOnly` | `true`, `false` | social profiles in the row; only companies with a named person's address |
| `pagesPerSite`, `includePhones` | `6`, `true` | the home page + up to N − 1 contact / imprint / about / team pages; phones in E.164 |
| `llmModel` | — | another model: an OpenRouter slug via Apify, `provider:model` with your own key |
| `dedupeStoreName` | `company-contact-finder-ai-seen` | the cross-run memory; one per client |
| `aiCandidateMultiplier` | `2` | AI pool = this × `maxQualified`, best rule score first |
| `maxToProcess` | 3 × `maxQualified` | companies looked up per run at most |
| `discoveryInput`, `discoveryTimeoutSecs` | `{}`, `3600` | raw input for the Store contact scraper, and the time limit of a Store fallback run |

## Which delivery field wins

Every URL is posted once; an explicit per-service field (API only) wins over the same service in **Send alerts to**;
`webhookUrl` gets the rows as JSON, the chat services and `notifyEmail` get the digest.

## Webhook payloads

`webhookUrl` POSTs `{"event": "company_contact.qualified", "company_contact": {…}, "runId": "…"}` per company; with
`webhookBatchSize` > 1 one POST per batch, `{"event": "company_contacts.qualified", "company_contacts": [{…}], "runId":
"…"}`. A 429 / 5xx is retried once after 2 s; a failed POST never fails the run and is re-sent next run;
`webhookSecret` signs every POST (`X-Signature: sha256=<HMAC-SHA256 of the raw body>`).

## Chain it after a scraper or a buyer finder

Any Apify dataset of companies is input: `{"sourceMode": "dataset", "datasetId": "<the other run's dataset>"}`. Its
`name` / `companyName` / `consigneeName` / `shipperName` / `buyerName` / `title` are the name, `country` / `countryCode`
the country, `website` / `domain` / `url` the website. A `vdrmota/contact-info-scraper` dataset (one row per page) is
merged per domain and its addresses checked. In the other Actor's **Integrations** tab add "Run succeeded → start
another Actor" with this Actor and that input, `datasetId` set to `{{resource.defaultDatasetId}}`.
