North Data via Cormorant
Cormorant fetches, parses and caches North Data (northdata.com /
northdata.de) company profiles for you: discovery of the profile URL from a
company name, the parsed profile with its register data, history, related
companies and officers, and North Data's network graph. You send JSON and
get structured JSON back, with the provenance of every page and the exact
bytes behind it.
This is the scraping path (the public pages); it does not use North Data's paid API.
Four endpoints#
| You have | Call | You get |
|---|---|---|
| a name, want candidates | POST /v1/northdata/suggest |
North Data's autocomplete, companies only |
| a name (+ country, headquarters) | POST /v1/northdata/discover |
the best profile URL with its score |
| a URL, or a name to discover | POST /v1/northdata/profile |
the parsed profile (+ network graph) |
an entity_id |
POST /v1/northdata/graph |
the network graph alone |
Most clients only need profile: give it the company name, country and headquarters and it discovers the URL, fetches the page, parses it and merges the graph in one call.
curl -s https://api.cormorant.ch/v1/northdata/profile \
-H "Authorization: Bearer $CORMORANT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"company_name": "Novartis Pharma AG", "country": "CH",
"headquarters": "Basel, Switzerland", "session_key": "CH0012005267"}'
import httpx
client = httpx.Client(
base_url="https://api.cormorant.ch",
headers={"Authorization": f"Bearer {key}"},
timeout=180,
)
r = client.post(
"/v1/northdata/profile",
json={
"url": "https://www.northdata.com/Unilever+plc",
"session_key": "GB00B10RZP78",
},
)
body = r.json()
if body["error"]:
print("no profile:", body["error"], body["retry_after_s"])
else:
profile = body["profile"]
print(profile["name"], profile["register_id"], profile["city"])
print("page bytes:", body["fetch"]["body_url"])
Suggest, discover and graph work the same way:
curl -s https://api.cormorant.ch/v1/northdata/suggest \
-H "Authorization: Bearer $CORMORANT_API_KEY" -H "Content-Type: application/json" \
-d '{"query": "Novartis", "country": "CH"}'
# {"op": "suggest", "query": "Novartis", "country": "CH",
# "items": [{"name": "Novartis Pharma AG", "url": "https://www.northdata.com/…", "type": "company"}, …],
# "fetch": {…}, "error": null, …}
curl -s https://api.cormorant.ch/v1/northdata/discover \
-H "Authorization: Bearer $CORMORANT_API_KEY" -H "Content-Type: application/json" \
-d '{"company_name": "Novartis Pharma AG", "headquarters": "Basel", "country": "CH"}'
curl -s https://api.cormorant.ch/v1/northdata/graph \
-H "Authorization: Bearer $CORMORANT_API_KEY" -H "Content-Type: application/json" \
-d '{"entity_id": "1234567", "session_key": "CH0012005267"}'
session_key and country#
session_keykeeps one residential exit for the company: suggest, profile and graph requests with the same key go out through the same Massive exit for 15 minutes ("flex" mode: single errors do not rotate it). Use a stable identifier of the company you are researching, such as its ISIN. After a proxy error the request is retried once on a fresh exit ({session_key}-{8 hex}).countryis the company's or research market's country: ISO code (CH) or an English or German name (Switzerland,Schweiz). It filters suggest results and geotargets the exit. Unknown names are ignored, never cut to two letters.
How Cormorant fetches North Data#
- Only through Massive, never from Cormorant's own addresses. The
residential proxy goes first, with the headers of one consistent desktop
Chrome (the same User-Agent, client hints and language on pages and on the
site's JSON calls). Massive Web Render follows when the proxy was
blocked (a challenge, 403/429/451/5xx, a refusal) or the page was thin.
North Data's fraud prevention refuses most residential exits, so after a
refusal Cormorant skips the proxy for an hour and goes straight to Web
Render (about 1 in 10 requests still tries the proxy, and a success ends
the skip). The network graph is requested exactly as North Data's own page
requests it (a
GETofdata.jsonwith the page'sextratoken), so Web Render can fetch it as well. There is no direct, browser or archive tier. Every request therefore uses a paid tier:allow_paid: falseanswerspaid_tier_required, an exhausted budgetbudget_exhausted. The same policy applies when you fetch these hosts withPOST /v1/fetch. - One throttle for the whole site.
.comand.decount as one site. Across the whole cluster, at most 2 requests are in flight and starts are at least 1 s apart, for suggest, profile and graph alike. A 429 or a challenge answered to Web Render slows the site down further for a while (a refused residential exit does not: that limit is per exit, not for the cluster). Synchronous calls wait up to 60 s for a slot; batch items go back to the queue instead. - Bot protection (Cloudflare, Akamai, DataDome, PerimeterX, Imperva, AWS
WAF, Sucuri, captcha and "unusual traffic" pages) is detected and never
served or cached as a profile. Thin pages (under 200 characters of text) and
pages that do not parse as a profile (
parse_failed) are not cached either. - Domain memory. When every tier failed for the site in 3 consecutive
requests, the site is skipped for a while (
blockedwithretry_after_s). Cormorant never learns a cheaper start tier for North Data.
Caching#
| What | Served from the cache for | Then |
|---|---|---|
| Profile pages | 24 h | revalidated with a conditional request through the proxy (fetch.revalidated: true when unchanged); entries expire after 7 days |
suggest.json answers |
24 h | fetched again |
data.json (graph) answers |
24 h | fetched again |
cache_ttl_s sets the maximum age you accept (0 bypasses the cache). The
cache is shared: a profile one project fetched costs the next project
nothing. Cache hits answer cached: true and billing_category: northdata_cached.
robots.txt#
Cormorant checks North Data's robots.txt for CormorantBot (and *)
before every profile page and before suggest.json and data.json. The
file is read through the residential proxy, like everything else, and cached
for 24 h. A disallowed URL answers robots_disallowed without fetching.
We made that call for the JSON endpoints on purpose. They are not documented APIs but the requests North Data's own pages make, and robots.txt is the site's statement about automated access to any path. So Cormorant follows it for them too, instead of exempting them as "API calls". At the time of writing, North Data's robots.txt blocks only a list of named crawlers (AI and archive bots) and none of the paths Cormorant uses.
Errors and retries#
Upstream failures are HTTP 200 with error set, never an HTTP 5xx. Errors
worth retrying are rate_limited and blocked (both after retry_after_s),
timeout, politeness_timeout and all_tiers_failed (later, with backoff).
not_found, parse_failed and invalid_identifier are final for that input.
paid_tier_required and budget_exhausted are about your request or
project. A profile whose graph failed is still returned, with graph_error
set.
Many companies: batch#
For more than a handful of companies, submit one job:
{
"items": [
{"op": "profile", "company_name": "Novartis Pharma AG", "country": "CH", "session_key": "CH0012005267"},
{"op": "profile", "url": "https://www.northdata.com/Unilever+plc", "session_key": "GB00B10RZP78"}
],
"idempotency_key": "research-run-2026-10-20-northdata",
"callback_url": "https://example.org/hooks/cormorant"
}
POST /v1/batch/northdata returns a job_id. Poll GET /v1/jobs/{job_id}
or wait for the callback, then page through ?include=items. Each item's
result has the shape of the matching single endpoint. The site throttle sets
the pace, about one profile per second across the cluster, so plan large runs
accordingly.
Costs#
Every uncached North Data request goes through Massive: the residential
proxy is billed by traffic, Web Render by request. A profile call can make up
to three requests (suggest, page, graph). cost_usd (for projects without a
customer account) and GET /v1/usage (the northdata block, the
northdata / northdata_cached categories) show what it cost; see
Costs & billing.
What a profile contains, and checking the match#
- Officers come from the page's management sections and its Persons
timeline (full name, role, start and end date, e.g. Roland Busch, CEO since
2021-03-15), then from the network graph (role
person). North Data's free pages show a selection of persons; complete lists (for example every authorised signatory) are part of its paid service and not available here. - Related companies: the page's subsidiary/parent/shareholder lists,
merger, acquisition and control partners from its M&A timeline (relation
merger,acquisition,control), and the graph's companies (related). - Network:
network_graphis North Data's graph; when that cannot be fetched (graph_error), it is built from the page's persons and related companies and sayssource: "page". - Is it the right company? Check
statusandidentifiersbefore you use a discovered profile. A name match can be a branch or a foreign registration: "Novartis Pharma AG, Basel" on North Data is (also) the company's registration in Norway's Brønnøysund register (identifiers: "The Brønnøysund Register Centre 925942480", EUIDNOFOR.925942480), terminated in 2024 (status: "Terminated"). Itsregister_idBR 925942480is that Norwegian number, not a Swiss UID.
Reference#
Company profiles, discovery and network graphs from North Data
(northdata.com / northdata.de). All four endpoints are synchronous (like
POST /v1/fetch), authenticated, metered (kind northdata, see Billing
categories) and answer HTTP 200 with error for upstream failures — never
an HTTP 5xx. For many companies use POST /v1/batch/northdata.
Transport. North Data is fetched only through Massive: the residential
proxy first (Chrome-consistent headers; a sticky exit per session_key,
15 minutes, retried once with a fresh exit after a proxy error), then
Massive Web Render when the proxy was blocked. North Data's fraud prevention
usually refuses residential exits (429, 403 for data.json): after a refusal
the proxy is skipped for an hour (NORTHDATA_PROXY_SKIP_S; 10 % of requests
still try it) and requests go straight to Web Render (attempt reason
proxy_skipped_refused). The network graph is requested like North Data's
own page does: GET /data.json?domain=company&id=…&type=graph&extra=<token>,
so Web Render can serve it too. Never from Cormorant's own
addresses: no direct, browser or archive tier and no alternative
sources. This also applies to POST /v1/fetch of these hosts. allow_paid: false (or a key without paid fetches) answers error: paid_tier_required.
A cluster-wide throttle covers the whole site (.com and .de together;
suggest, profile and graph): by default at most 2 requests in flight and
1 s between starts. robots.txt is read through the proxy and applies to
profile pages, suggest.json and data.json.
Cache. Profile pages: served for 24 h, then revalidated with a conditional
request (fetch.revalidated), kept 7 days. Suggest and graph answers: 24 h.
Challenge pages, thin pages (< 200 characters of text) and pages that do not
parse are never kept. cache_ttl_s sets the maximum acceptable age (0
bypasses the cache).
| Endpoint | Request | Response |
|---|---|---|
POST /v1/northdata/suggest |
query, country?, session_key? |
items[{name, url, type}] (companies only, absolute URLs), fetch |
POST /v1/northdata/discover |
company_name, headquarters?, country?, session_key?, min_similarity? (70), search_fallback? (true) |
url, query, score (0..1), name_similarity (0..100 or null), discovery_method (suggest/search), candidates[≤10 {url, name, score, query, title}], suggest_fetch |
POST /v1/northdata/profile |
url or company_name (+ country, headquarters), session_key?, country?, include_graph? (true), min_similarity?, search_fallback? |
profile (below), fetch, discovery (when discovered), graph_fetch, graph_error |
POST /v1/northdata/graph |
entity_id, session_key?, country? |
entity_id, company_connections, person_connections, other_connections, fetch |
Every request also takes cache_ttl_s, allow_paid (default true) and
project_ref. Every response has op, error, retry_after_s, cost_usd
(null for external customers), billing_category (northdata or
northdata_cached) and request_id. country is an ISO code (CH) or an
English/German country name (Switzerland); unknown names are ignored. It
filters suggest (countries=) and geotargets the Massive exit.
{
"op": "profile",
"profile": {
"name": "Novartis Pharma AG", "legal_form": "AG", "register_id": "BR 925942480",
"court": null, "status": "Active", "address": "Lichtstrasse 35, 4056 Basel, Switzerland",
"city": "4056 Basel", "country": "Switzerland", "path_country": "CH",
"entity_id": "…", "subsidiaries": [{"name": "Novartis International AG",
"relation": "subsidiaries", "url": "/Novartis%20International%20AG",
"absolute_url": "https://www.northdata.com/Novartis%20International%20AG"}],
"source_url": "https://www.northdata.com/Novartis%20Pharma%20AG,%20Basel,%20CH/BR%20925942480",
"discovery_method": "suggest", "discovery_score": 1.0, "name_similarity": 100.0,
"fetch_tier": "web_render", "parser_version": "4", "provider": "northdata"
},
"fetch": {
"url": "https://www.northdata.com/…", "final_url": "https://www.northdata.com/…",
"status": 200, "tier": "proxy", "body_url": "/v1/bodies/9f2c…", "sha256": "9f2c…",
"body_kind": "origin", "retrieved_at": "2026-10-19T12:00:00+00:00", "cached": false,
"revalidated": false, "attempts": [{"tier": "proxy", "status": 200, "reason": "ok", "ms": 2100}],
"cost_usd": 0.0004, "parser_version": "4"
},
"discovery": {"url": "https://www.northdata.com/…", "query": "Novartis Pharma AG",
"score": 1.0, "name_similarity": 100.0, "discovery_method": "suggest", "candidates": []},
"graph_fetch": null, "graph_error": "no_entity_id",
"error": null, "retry_after_s": null, "cost_usd": 0.0006,
"billing_category": "northdata", "request_id": "req_…"
}
Profile fields (parser_version 4; parses HTML and the Markdown Web
Render sometimes returns): name, legal_form (from the company's own name
or legal-form block), register_id (check identifiers for which register it
belongs to), court (German register court as shown, e.g. Amtsgericht München or District Court of Munich), status (Terminated /
Liquidation when North Data marks it so, else Active for a register
profile), identifiers[{label, source, value, current}] (the Identification
block: register entries, LEI, EUID, …), address, city,
country (free text, not normalised), path_country (ISO code in the URL),
entity_id, lei, industry, former_names[], former_addresses[],
corporate_purpose, additional_info, history[{category, date, description, text, url}], publications[{date, summary, company_name}],
mentions[…], network_graph {company_connections, person_connections, other_connections, source} (source: data.json, or page when the graph
could not be fetched and the network is built from the page's persons and
merger/acquisition data), officers[{name, role, since, until, url, absolute_url}] (from the page's management sections and its Persons
timeline, e.g. Roland Busch, CEO, since 2021-03-15, then graph persons),
shareholders/subsidiaries/parent_companies/related (related also
holds merger/acquisition/control partners of the page's M&A timeline)
[{name, relation, url, absolute_url}], financials (always null),
source_url, discovery_query, discovery_score (0..1),
discovery_method, name_similarity (0..100), scraped_at, fetch_tier
(proxy or web_render), parser_version, provider (northdata).
Graph merge: graph companies not already listed become related (relation
related); graph persons become officers with role person; parents and
subsidiaries are never invented.
Discovery. Suggest first: company items only; the one with the highest
name similarity (Jaro-Winkler + Levenshtein on names without legal
suffixes, 0..100) wins when it reaches min_similarity; score = similarity
/ 100; no first-result fallback. Otherwise, with search_fallback,
Cormorant's own search with up to four queries (northdata "{name}" {city cc}, site:northdata.com OR site:northdata.de "{name}" {city or cc},
northdata "{name}" {cc}, northdata {name}), 5 results each, profile URLs
scored 0..1 (name tokens in the path and title, city, country, host, register
in the path); it stops at ≥ 0.75 and rejects < 0.35. Suggest candidates carry
the name similarity (0..100) as score, search candidates the URL score.
error |
Meaning |
|---|---|
not_found |
no profile (404/410, or discovery found nothing) |
blocked |
every Massive tier was blocked (challenge, 403/451, content policy); with retry_after_s while the site is skipped |
rate_limited |
North Data answered 429; retry after retry_after_s |
all_tiers_failed |
every tier failed for another reason (network, 5xx, not JSON) |
parse_failed |
the page is not a company profile (e.g. a search page); not cached |
invalid_identifier |
url is not a North Data profile URL, or entity_id is malformed |
paid_tier_required |
allow_paid: false or the key has no paid fetches: North Data needs Massive |
budget_exhausted |
the project's monthly budget leaves nothing for Massive |
robots_disallowed |
robots.txt disallows the URL for CormorantBot/* |
timeout |
the request ran out of time (sync calls stop before the API's wait) |
politeness_timeout |
the site throttle stayed busy (transient; batch items retry) |
graph_error (profile only): why the graph is missing while the profile is
returned — an error code from the table, or no_entity_id.
Batch: POST /v1/batch/northdata#
{
"items": [
{"op": "profile", "company_name": "Novartis Pharma AG", "country": "CH", "session_key": "CH0012005267"},
{"op": "graph", "entity_id": "1234567"}
],
"idempotency_key": "chipiron-run-2026-10-20-northdata-0001",
"callback_url": "https://chipiron.example/hooks/cormorant"
}
op is suggest, discover, profile (default) or graph, plus the fields
of that endpoint (422 when the field an operation needs is missing). Items
run like the single endpoints and their results have the same shape; the
site throttle sets the pace (a busy throttle puts an item back in the queue).
Coming from Chipiron's own scraper#
The profile follows Chipiron's NorthdataProfile, with these differences:
| Chipiron | Cormorant |
|---|---|
discovery_method: "brave" |
"search" (Cormorant's own search, Brave only as its fallback) |
entity url relative only |
url as on the page, plus absolute_url |
officers {name, role} (role = "officers"/"person") |
{name, role, since, until, url, absolute_url}; role is the stated role when the page has one |
fetch_tier direct/browser/proxy/web_render |
only proxy or web_render (never our own addresses) |
parser_version: "2" |
"4" |
status only when the page has a status field |
Terminated / Liquidation from North Data's ✝︎ marker or the register history, else Active |
| — | identifiers (the Identification block, with each register's name) |
network_graph only from data.json |
also built from the page (source: "page") when the graph cannot be fetched |
| — | new: path_country, lei, industry |
| identity acceptance against the issuer | stays in Chipiron: use name, country, city, path_country, register_id, name_similarity and discovery_score |