diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ef1122..780a174 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -50,6 +50,8 @@ jobs: # Advisory: tells us when the live API has moved past our vendored copy. # Never blocks — the SDK targets the spec it ships with, and a drifting # upstream is a signal to cut a release, not a reason to fail this build. + # Compares the whole document (paths, schemas, descriptions), ignoring only + # `servers`. upstream-drift: runs-on: ubuntu-latest if: github.event_name == 'push' @@ -58,17 +60,43 @@ jobs: - uses: actions/checkout@v4 - name: Compare vendored spec against production run: | - curl -sSL https://api.firmendata.com/v1/openapi.json -o /tmp/live.json + curl -fsSL https://api.firmendata.com/v1/openapi.json -o /tmp/live.json python - <<'PY' import json - live = json.load(open('/tmp/live.json')) - vendored = json.load(open('contracts/openapi.v1.json')) - lp, vp = set(live['paths']), set(vendored['paths']) - if lp != vp: - print(f"::warning::Vendored spec is out of date. " - f"added={sorted(lp - vp)} removed={sorted(vp - lp)}") + + def load(path): + spec = json.load(open(path)) + spec.pop("servers", None) + return spec + + def diff(a, b): + a, b = a or {}, b or {} + return { + "added": sorted(set(a) - set(b)), + "removed": sorted(set(b) - set(a)), + "changed": sorted(k for k in set(a) & set(b) if a[k] != b[k]), + } + + live = load("/tmp/live.json") + vendored = load("contracts/openapi.v1.json") + if live == vendored: + print("Vendored spec matches production (ignoring servers).") else: - print("Vendored spec matches production paths.") + summary = { + "version": { + "live": live.get("info", {}).get("version"), + "vendored": vendored.get("info", {}).get("version"), + }, + "sections": sorted( + k for k in set(live) | set(vendored) if live.get(k) != vendored.get(k) + ), + "paths": diff(live.get("paths"), vendored.get("paths")), + "schemas": diff( + live.get("components", {}).get("schemas"), + vendored.get("components", {}).get("schemas"), + ), + } + print(f"::warning::Vendored spec is out of date. {json.dumps(summary)}") PY secrets: diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e8d84c..3b7ed31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,23 +2,72 @@ ## 1.3.0 -Regenerated from the updated API contract. **Additive, non-breaking:** +Regenerated from the updated API contract. **Additive, non-breaking**, except +where noted for `get_financials()`. + +**Coverage and fields** + +- Austria: `country="AT"`, the Austrian register type (`AT-FB`), Firmenbuch + courts in `register_court`, the Austrian states in `bundesland`, and + Austrian legal forms. +- Optional `country` (`DE`, `CH` or `AT`) on autocomplete in both clients; + autocomplete hits include `registered_seat`. +- Company contact details include `phones` (phone and fax numbers) and + `primary_email`. +- Financial summaries include `latest_equity`; each year in + `history["metrics"]` carries its top-level `balance_sheet` and + `income_statement` positions. +- Error messages fall back to the problem's new `message` field. + +**New methods** -- Optional `country` (`DE`, `CH` or `AT`) on autocomplete in both clients. - Beta `feed_new_companies(**filters)` and `feed_insolvencies(**filters)` methods on sync and async clients, with generated filter and response types. + Both accept `register_court` (German Registergericht or Austrian Firmenbuch + court names, repeated values combined with OR); on the insolvency feed it + filters by the company's register court, not the insolvency court. - `iter_feed_new_companies` and `iter_feed_insolvencies` drain available pages while `has_more` is true, deduplicate event IDs within each run, and expose `.next_cursor` after fully consumed pages for resuming, including empty pages. Async variants use `async for`. -- Both feeds accept `register_court` (German Registergericht or Austrian - Firmenbuch court names, repeated values combined with OR); on the insolvency - feed it filters by the company's register court, not the insolvency court. -- Generated types reflect Austrian countries, register types, courts, states - and legal forms, plus balance-sheet and income-statement positions and - latest equity in financial responses. -- README documents feed polling, cursor persistence, deduplication and pricing: - 1 credit per page with events; empty pages are free. +- `get_lists()` and `get_list(list_id, limit=, cursor=, sort=, sort_direction=)` + on both clients read your company lists. Both are free. +- `iter_search(..., max_results=None)` on both clients yields every search hit, + following `next_cursor` with only the cursor and `limit` after the first page, + and stops at `has_more=False`, `max_results` or 10,000 results. Each page of + up to 50 results costs 1 credit. +- `create_subscription(**body)` and `test_delivery(**body)` are typed from the + contract (`SubscriptionCreateRequest`, `SubscriptionTestRequest`) instead of + `**body: Any`. + +**Financials** + +- `get_financials()` takes `details`. The API now returns the full line items + by default; pass `details=False` for the lean response. `include_line_items` + is deprecated: it emits a `DeprecationWarning` and is sent as `details` (it + no longer sends `include=line_items`). Calls that passed nothing now receive + the full line items, as the API does. + +**Client behaviour** + +- Every request sends `X-FirmenData-Client: python-sdk/`; the + `User-Agent` stays `firmendata-python/`. The new + `app_info={"name": ..., "version": ..., "url": ...}` option appends your + integration to it. +- New `on_response` option: called for every HTTP response with a + `ResponseInfo` (`status`, `headers`, `request_id`, `credits_charged`). The + async client also awaits a coroutine callback. +- Realtime calls (`fetch_realtime=True`), document listings and downloads are + no longer retried after a timeout, a gateway timeout or a connection that + broke after the request was sent — the server bills them when it finishes, + so a retry could be billed twice. Their timeout is at least 150 seconds + (`REALTIME_TIMEOUT`) unless the call passes its own `timeout`, which the + live-capable methods now accept. +- A `429` whose `Retry-After` exceeds 20 seconds raises `RateLimitError` at + once, with `retry_after` in seconds, instead of waiting 20 seconds per retry + and failing anyway. +- The generator toolchain (`datamodel-code-generator`, `black`) is pinned in + the `dev` extra; CI also runs on Python 3.14. ## 1.2.1 diff --git a/README.md b/README.md index 5091f8e..d585917 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # firmendata-python Official Python client for the [firmendata](https://firmendata.com) API — data on -German and Swiss companies from the Unternehmensregister, Handelsregister -and Swiss commercial register: +German, Swiss and Austrian companies from the Unternehmensregister, +Handelsregister, the Swiss commercial register and the Austrian Firmenbuch: register search, parsed annual financial statements, company profiles, register documents, and ownership chains for KYC. @@ -29,13 +29,13 @@ Optionally pass `country="DE"`, `"CH"` or `"AT"` to restrict autocomplete to one company register; omitting it searches all countries. Keyless calls are rate limited, modestly and by address — enough to try the -API, back a search box, or run low-volume queries. Add a key for substantially -higher limits plus every other endpoint. On a `429`, honour `Retry-After`; -the client already does this for you. +API or run low-volume queries. Add a key for substantially higher limits plus +every other endpoint. On a `429`, honour `Retry-After`; the client already +does this for you (see [Retries](#retries)). ## With an API key -Create one at [firmendata.com](https://firmendata.com/de/account/api-keys) — the +Create one at [firmendata.com](https://firmendata.com/de/developer/api-keys) — the free plan includes 100 credits. ```python @@ -46,9 +46,9 @@ fd = FirmenData(api_key="firmendata_live_...") ## Search the register -The main entry point: search German and Swiss companies. Different filters -combine with AND, repeated values with OR; `city`, `bundesland` and `canton` -combine into one location filter with OR. +The main entry point: search German, Swiss and Austrian companies. Different +filters combine with AND, repeated values with OR; `city`, `bundesland` and +`canton` combine into one location filter with OR. ```python results = fd.search( @@ -68,21 +68,36 @@ if results["pagination"]["has_more"]: ``` Pass `next_cursor` back unchanged — cursors are opaque and signed, and an -edited one is rejected. Unknown filter names, unknown values and inverted +edited one is rejected. A cursor remembers the filters it was issued for, so +it can be sent alone. Unknown filter names, unknown values and inverted ranges (`revenue_min` above `revenue_max`) raise `ValidationError` instead of being ignored. +`iter_search()` does the paging for you: it yields every hit, sends only the +cursor (and `limit`) after the first page, and stops when `has_more` is false, +after `max_results` hits, or at the 10,000 results a query can return. **Each +page of up to 50 results costs 1 credit**, like a `search()` call — set +`max_results`, or stop iterating, to stop paying for pages you do not need. + +```python +for hit in fd.iter_search(bundesland=["Bayern"], max_results=500): + print(hit["eu_id"], hit["display_name"]) +``` + Values are case-insensitive and tolerate German spelling both ways — `gmbh`, `muenchen`, `NRW` and `Bavaria` all resolve. Filter by legal form, legal status, register court, federal state, city, industry, founding date, size, web presence, connected person or EU public-procurement role; see the [filter reference](https://api.firmendata.com/v1/docs#tag/Search). -Use `country="DE"` or `country="CH"` for a single country and -`canton=["ZH", "BE"]` for Swiss cantons. `rechtsform` includes Swiss forms -such as `"AG (CH)"` and `"GmbH (CH)"`. `sort="name"` defaults to ascending. -Search hits include `registered_seat`; company profiles, search and -autocomplete hits include `country_code` (`"DE"`, `"CH"` or `"AT"`). +Use `country="DE"`, `"CH"` or `"AT"` for a single country and +`canton=["ZH", "BE"]` for Swiss cantons; `bundesland` also takes the Austrian +states (`"Wien"`, `"Tirol"`, …). `rechtsform` includes Swiss and Austrian +forms such as `"AG (CH)"`, `"GmbH (CH)"` and `"GmbH (AT)"`, and +`register_court` the Austrian Firmenbuch courts (`"Handelsgericht Wien"`, …). +`sort="name"` defaults to ascending. Search and autocomplete hits include +`registered_seat`; company profiles, search and autocomplete hits include +`country_code` (`"DE"`, `"CH"` or `"AT"`). > **Filtering on size? Use `total_assets`, not `revenue`.** Small and > medium-sized German companies file abridged accounts — a balance sheet, but @@ -93,41 +108,49 @@ autocomplete hits include `country_code` (`"DE"`, `"CH"` or `"AT"`). ## Company profile ```python -eu_id = "DEB1103R_HRB123456" # from search or autocomplete +eu_id = "DEF1103R.HRB12300B" # from search or autocomplete company = fd.get_company(eu_id) history = fd.get_history(eu_id) # chronological register entries ``` -Identity and seat, register reference, legal status resolved from the merged -Handelsregister and Insolvenzbekanntmachungen timelines, industry -classification, contact details and web presence. +Identity and seat, register reference, legal status from the commercial +register and insolvency notices, industry classification, contact details +(including `phones` and `primary_email`) and web presence. ## Financial statements Filed annual accounts, parsed into figures rather than handed to you as PDFs. -The deepest part of the dataset: German companies must publish, and we parse -what they file into structured multi-year figures. +The deepest part of the dataset: companies in Germany and Austria must publish +their accounts, and we parse what they file into structured multi-year +figures. ```python financials = fd.get_financials(eu_id) summary = financials["summary"] if summary: - print(summary["latest_fiscal_year"], summary["latest_total_assets"]) + print(summary["latest_fiscal_year"], summary["latest_total_assets"], + summary["latest_equity"]) for year in financials["history"]["metrics"]: print(year["year"], year["balance_sheet_total"], year["revenue"], year["profit"]) ``` -The response is lean by default. The structured `profit_and_loss`, `assets` -and `liabilities_and_equity` rows as filed are opt-in, and `years` trims every +By default the response carries every filed `profit_and_loss`, `assets` and +`liabilities_and_equity` row at every depth — often 100 KB or more for +groups. Pass `details=False` for the lean response: those lists stay empty, +and each year in `history["metrics"]` keeps its top-level `balance_sheet` and +`income_statement` positions. Both cost the same. `years` trims every per-year array to the most recent fiscal years: ```python -full = fd.get_financials(eu_id, include_line_items=True, years=5) +lean = fd.get_financials(eu_id, details=False, years=5) ``` +`include_line_items` from earlier versions still works but is deprecated: it +emits a `DeprecationWarning` and is sent as `details`. + `employee_history` and the underlying `financial_publications` are always included; `relationships["subsidiaries"]` lists the first 25, with `subsidiaries_total` for the count. @@ -161,19 +184,22 @@ answers are unbilled. Swiss companies return an empty list with `coverage["status"] == "not_applicable"`. Pass a `doc_` identifier from the list as `document_id` with its matching -`file_type` to download a specific DK version. Omit it for the latest version. +`file_type` to download a specific version. Omit it for the latest version. It cannot be combined with `file_id` or `fetch_realtime=True`. Register extracts have no `document_id`; download them by `file_type`. Download responses include `document_id` and the registry `label` when available. +Document listings and downloads are live calls — see +[Realtime calls](#realtime-calls). + ## Ownership: shareholders and UBO Cap tables and beneficial-owner chains, for **KYC and AML workflows**. **Pass `fetch_realtime=True` on both of these.** Unlike the endpoints above, -which read an index we keep continuously fresh, cap tables are parsed from the -filed Gesellschafterliste on demand — the flag fetches and parses the current -filing for the company (and, for `get_ubo`, every German company in its +which answer from data we keep continuously up to date, cap tables are parsed +from the filed Gesellschafterliste on demand — the flag fetches and parses the +current filing for the company (and, for `get_ubo`, every German company in its ownership chain). Without it you are limited to whatever has already been parsed, and will often get `not_filed` for a company that has in fact filed. It costs more credits and takes a few seconds per company in the chain, which @@ -214,9 +240,27 @@ print(ubo["coverage"]["status"], ubo["beneficial_owners"]) independently tested against the 25% threshold, so a sub-threshold link breaks the chain entirely. +## Realtime calls + +Calls with `fetch_realtime=True` (`autocomplete`, `search`, `get_company`, +`get_shareholders`, `get_ubo`, `get_history`, `download_document`), +`list_documents` and every `download_document` reach the registers live. +Realtime calls cost more credits (see +[pricing](https://firmendata.com/en/plans)) and can take up to about two +minutes. They are **billed when the server finishes them, even if your client +has already given up**, so the client treats them differently: + +- It waits at least 150 seconds (`firmendata.REALTIME_TIMEOUT`) for them, or + your client `timeout` if that is longer. A `timeout` passed to the call + itself always wins: `fd.get_ubo(eu_id, fetch_realtime=True, timeout=60)`. +- It **never retries one after a timeout**, or after a connection that broke + once the request was sent — the retry could be billed a second time. It + raises `APITimeoutError` (or `APIConnectionError`) instead; the call may + still have completed and been billed. + ## Event feeds (Beta) -Poll newly discovered companies with `feed_new_companies(**filters)` or +Poll newly registered companies with `feed_new_companies(**filters)` or insolvency notices with `feed_insolvencies(**filters)`. Both require an API key and return `data` (a list of `FeedEvent` dictionaries), `next_cursor` (always present) and `has_more` at the top level. @@ -234,9 +278,9 @@ Pass `next_cursor` back unchanged with the same filters. When `has_more` is true, request the next page immediately; otherwise poll again later. On the first call only, `since` accepts an ISO datetime within the last seven days; omitting it starts from the last day. `limit` is 1–100 and defaults to 50. -Cursors survive restarts and deploys, but discovery positions older than -seven days expire; restart with a recent `since`. Each page with events costs -1 credit; empty pages are free. Events arrive in discovery order after a +Cursors survive restarts, but positions older than seven days expire; restart +with a recent `since`. Each page with events costs 1 credit; empty pages are +free. Events arrive in the order they become available, after a short settlement delay. Both feeds accept `country` (`DE`, `CH` or `AT`), `bundesland`, `canton`, @@ -244,10 +288,11 @@ Both feeds accept `country` (`DE`, `CH` or `AT`), `bundesland`, `canton`, keys and match with OR. `register_court` takes German Registergericht or Austrian Firmenbuch court names (the same values as search); select Swiss companies with `canton`. On the insolvency feed it is the company's register -court, not the insolvency court. Insolvencies default to `country="DE"` and also accept `category`, -`revenue_min` (inclusive) and `revenue_max` (exclusive). Swiss new-company -events come from the SHAB new-company scanner; Swiss Schuldenruf notices -require an explicit category selection: +court, not the insolvency court. Insolvencies default to `country="DE"` and +also accept `category`, `revenue_min` (inclusive) and `revenue_max` +(exclusive). Swiss new-company events cover newly registered companies in the +Swiss commercial register; Swiss Schuldenruf notices require an explicit +category selection: ```python page = fd.feed_insolvencies(country="CH", canton=["ZH"], category=["Schuldenruf"]) @@ -272,8 +317,9 @@ The cursor starts at the supplied cursor (or `None`) and advances when iteration continues past a page's events. If processing stops partway through a page, the checkpoint stays at that page's starting position so it can be replayed. Iterators deduplicate event `id` values within one run; persist -processed IDs across polls and restarts too, because a reparse can resurface -an ID. Save a cursor only after successfully processing its page's events. +processed IDs across polls and restarts too, because the same event can +appear again in a later run. Save a cursor only after successfully processing +its page's events. Async clients have the same page methods with `await`. Create async feed iterators without `await`, consume them with `async for`, then save their @@ -289,6 +335,25 @@ async with AsyncFirmenData(api_key="firmendata_live_...") as fd: next_cursor = events.next_cursor ``` +## Lists + +Company lists you build in the web app are readable through the API, for +free: + +```python +lists = fd.get_lists() # newest first +for entry in lists["data"]: + print(entry["list_id"], entry["name"], entry.get("company_count")) + +page = fd.get_list(lists["data"][0]["list_id"], limit=100, sort="revenue") +for row in page["data"]: + print(row["eu_id"], row["display_name"]) # same shape as search hits +``` + +`get_list` pages like search: pass `page["pagination"]["next_cursor"]` back as +`cursor`. `sort` orders the whole list before the page is cut. A list id that +does not exist or is not yours raises `NotFoundError`. + ## Subscriptions Get notified when a company's data changes, instead of polling: @@ -307,10 +372,16 @@ Webhook bodies are HMAC-SHA256 signed — verify `X-Firmendata-Signature` agains the secret returned at creation. Omit `notification_type` to poll `list_events()` on your own schedule instead. +Check your receiver before subscribing with `fd.test_delivery(eu_id=eu_id, +subscription_type="details", notification_type="webhook", webhook_url=...)`, +and deliver a past event again with `fd.resend_event(event_id)`. Both bodies +are typed (`SubscriptionCreateRequest`, `SubscriptionTestRequest`), so `mypy` +and editors flag a misspelt field. + ## Async Same methods, same semantics — request methods have an `await`able twin, -and feed iterators use `async for`: +and feed and search iterators use `async for`: ```python import asyncio @@ -318,9 +389,12 @@ from firmendata import AsyncFirmenData async def main(): async with AsyncFirmenData(api_key="firmendata_live_...") as fd: - company = await fd.get_company("DEB1103R_HRB123456") + company = await fd.get_company("DEF1103R.HRB12300B") print(company["display_name"]) + async for hit in fd.iter_search(q="siemens", max_results=100): + print(hit["eu_id"]) + asyncio.run(main()) ``` @@ -337,7 +411,7 @@ try: except InsufficientCreditsError: ... # top up or upgrade except RateLimitError as e: - ... # e.retry_after is the server's own hint + ... # e.retry_after: seconds to wait, from the server ``` | Exception | Status | Meaning | @@ -345,10 +419,10 @@ except RateLimitError as e: | `AuthenticationError` | 401 | Missing/invalid key, or a keyless call used a paid feature | | `TokenExpiredError` | 401 | Key expired | | `InsufficientCreditsError` | 402 | Balance too low for this call | -| `NotFoundError` | 404 | No such company, subscription or event | +| `NotFoundError` | 404 | No such company, list, subscription or event | | `ConflictError` | 409 | Conflicts with existing state | | `ValidationError` | 422 | Bad parameters — see `.errors` (`{"param", "message"}` each) | -| `RateLimitError` | 429 | Retry budget exhausted — see `.retry_after` | +| `RateLimitError` | 429 | Retries exhausted, or a long `Retry-After` — see `.retry_after` | | `ServerError` | 5xx | Retried automatically for idempotent calls | | `APIConnectionError` / `APITimeoutError` | — | No response at all | @@ -356,17 +430,67 @@ except RateLimitError as e: Automatic and deliberately conservative: -- **429 is always retried**, on any method — the server rejects rate-limited - calls before the handler runs, so nothing happened and nothing was billed. - The server's `Retry-After` is used verbatim. +- **429 is retried on any method** — the server rejects rate-limited calls + before the handler runs, so nothing happened and nothing was billed. The + client waits for the server's `Retry-After`, up to 20 seconds. When the + server asks for longer — the keyless daily limit can ask for hours — the + `RateLimitError` is raised at once, with `retry_after` in seconds, instead of + holding your call open. - **5xx and connection failures are retried only for idempotent methods.** A `create_subscription` that times out may already have been applied; replaying it would create a second one. +- **Realtime calls are never retried once they may have reached the server** + — see [Realtime calls](#realtime-calls). - Backoff is exponential with full jitter, so clients that trip the same limit together don't all return at the same instant. Tune with `FirmenData(max_retries=...)`; `0` disables it. +## Credits and response metadata + +Successful responses carry `X-Credits-Charged`, `X-Request-Id` and the +`RateLimit-*` headers. Pass `on_response` to see them: it is called for every +HTTP response — successes, errors, and responses the client then retries — +with a `ResponseInfo` holding `status`, `headers` (case-insensitive), +`request_id` and `credits_charged`. + +```python +from firmendata import FirmenData, ResponseInfo + +credits_used = 0 + +def track(info: ResponseInfo) -> None: + global credits_used + credits_used += info.credits_charged or 0 + print(info.status, info.request_id, info.headers.get("RateLimit-Remaining")) + +fd = FirmenData(api_key="firmendata_live_...", on_response=track) +fd.search(q="siemens") +print("credits used so far:", credits_used) +``` + +An exception raised in the callback reaches the caller of the method. On +`AsyncFirmenData` the callback may also be an `async def`; the client awaits +it. + +## Configuration + +```python +FirmenData( + api_key="...", # optional — omit for the free autocomplete tier + base_url="...", # default https://api.firmendata.com + timeout=30.0, # per attempt; realtime calls wait at least 150 + max_retries=2, + app_info={"name": "acme-crm", "version": "2.1.0", "url": "https://acme.example"}, + on_response=track, + http_client=None, # bring your own httpx.Client (AsyncClient for async) +) +``` + +Every request sends `X-FirmenData-Client: python-sdk/` and the +`User-Agent` `firmendata-python/`. `app_info` identifies your +integration by appending ` name/version (url)` to it. + ## Types Responses are plain dictionaries described by generated `TypedDict`s, so editors @@ -381,8 +505,9 @@ published spec: python scripts/generate_types.py ``` -CI regenerates them and fails if the result differs from what is committed, so -the SDK cannot silently drift from the API it targets. +CI regenerates them with the toolchain pinned in the `dev` extra and fails if +the result differs from what is committed, so the SDK cannot silently drift +from the API it targets. ## Development diff --git a/contracts/openapi.v1.json b/contracts/openapi.v1.json index caf5485..c5a9070 100644 --- a/contracts/openapi.v1.json +++ b/contracts/openapi.v1.json @@ -170,7 +170,7 @@ "type": "object" }, "AiInsightsBlock": { - "description": "AI-generated insights, derived from one filed annual report.", + "description": "Company summary and outlook based on one filed annual report.", "properties": { "based_on_fiscal_year": { "anyOf": [ @@ -1047,7 +1047,7 @@ "type": "object" }, "BoardMember": { - "description": "A current officer, from the replay of every appointment and departure.", + "description": "A company officer currently in office.", "properties": { "appointed_date": { "anyOf": [ @@ -1257,7 +1257,7 @@ "type": "string" }, "BusinessBlock": { - "description": "Business block: products, segments, mined keywords.", + "description": "Company products, business segments and keywords.", "properties": { "business_branches": { "description": "Reported business segments and their revenue split.", @@ -1275,7 +1275,7 @@ "type": "string" }, "opportunity_keywords": { - "description": "Opportunity indicators mined from management reports.", + "description": "Opportunity indicators mentioned in management reports.", "items": { "type": "string" }, @@ -1283,7 +1283,7 @@ "type": "array" }, "products": { - "description": "Products and services mined from filings.", + "description": "Products and services described in filings.", "items": { "type": "string" }, @@ -1291,7 +1291,7 @@ "type": "array" }, "risk_keywords": { - "description": "Risk indicators mined from management reports.", + "description": "Risk indicators mentioned in management reports.", "items": { "type": "string" }, @@ -1505,7 +1505,7 @@ "type": "null" } ], - "description": "AI-generated summary and outlook, localized. `null` when no AI insights are available for this company." + "description": "Company summary and outlook, localized. `null` when no company insights are available for this company." }, "business_info": { "anyOf": [ @@ -1516,7 +1516,7 @@ "type": "null" } ], - "description": "Business profile (products, segments, keywords). `null` when no products, segments, or mined keywords are available." + "description": "Business profile (products, segments, keywords). `null` when no products, segments, or keywords are available." }, "business_purpose": { "anyOf": [ @@ -1532,7 +1532,7 @@ }, "contact": { "$ref": "#/components/schemas/ContactBlock", - "description": "Contact and web presence (domain, logo, social profiles, LinkedIn enrichment)." + "description": "Contact and web presence (domain, logo, social profiles)." }, "country_code": { "description": "Country of the register the company is entered in: `DE` for the German registers (Handelsregister, Genossenschafts-, Partnerschafts-, Vereinsregister), `CH` for the Swiss commercial register, `AT` for the Austrian Firmenbuch.", @@ -1627,7 +1627,7 @@ }, "freshness": { "$ref": "#/components/schemas/Freshness", - "description": "How current the register snapshot in this response is. `last_checked_at` is the timestamp at which the company's structured register snapshot (SI) was last successfully fetched; `realtime_fetching_status` reports the outcome of the realtime attempt for this request. The chronological register history has its own freshness object served by `/v1/companies/{eu_id}/history`." + "description": "How current the register snapshot in this response is. `last_checked_at` is the timestamp at which the company's register profile was last successfully fetched; `realtime_fetching_status` reports the outcome of the realtime attempt for this request. The chronological register history has its own freshness object served by `/v1/companies/{eu_id}/history`." }, "industry_categorization": { "$ref": "#/components/schemas/IndustryBlock", @@ -1692,7 +1692,7 @@ }, "legal_status": { "default": "active", - "description": "Derived lifecycle status, resolved from the merged Strukturierte-Inhalte, Chronologisches-Datenblatt and Insolvenzbekanntmachungen timelines — the most recent event wins regardless of which source recorded it.\n\nSwiss companies' status comes from SHAB bankruptcy and Nachlass notices.\n\n`deleted` — company has been removed from the German registries.\n`insolvent` — insolvency proceedings are open or preliminary measures are in force.\n`in_liquidation` — the company is dissolved (Auflösung, whether by shareholder resolution or as a consequence of insolvency) and a Liquidator is in office.\n`dissolved` — dissolved with no Liquidator in office, or an insolvency proceeding that has been lifted (Aufhebung des Verfahrens).\n`active` — default for everything else.", + "description": "Company lifecycle status according to the commercial register and insolvency notices, including Swiss Official Gazette of Commerce (SHAB) bankruptcy and composition notices.\n\n`deleted` — company has been removed from the German registries.\n`insolvent` — insolvency proceedings are open or preliminary measures are in force.\n`in_liquidation` — the company is dissolved (Auflösung, whether by shareholder resolution or as a consequence of insolvency) and a Liquidator is in office.\n`dissolved` — dissolved with no Liquidator in office, or an insolvency proceeding that has been lifted (Aufhebung des Verfahrens).\n`active` — default for everything else.", "enum": [ "active", "in_liquidation", @@ -1955,7 +1955,7 @@ "type": "null" } ], - "description": "Catalog identifier echoed for a specific DK version.", + "description": "Catalog identifier echoed for the requested document version.", "title": "Document Id" }, "download_url": { @@ -2088,7 +2088,7 @@ "description": "Whether register document downloads apply to this company." }, "data": { - "description": "Register extracts first, then DK documents grouped by type, newest dated versions first and undated versions last. Includes older DK versions. Empty when no documents are available or downloads do not apply.", + "description": "Register extracts first, then other register documents grouped by type, newest dated versions first and undated versions last. Includes older document versions. Empty when no documents are available or downloads do not apply.", "items": { "$ref": "#/components/schemas/CompanyDocumentListItem" }, @@ -2102,7 +2102,7 @@ }, "freshness": { "$ref": "#/components/schemas/Freshness", - "description": "Outcome of this request's live catalog walk. `last_checked_at` is the successful check time, or null when the walk did not succeed. Failed walks serve the stored catalog without billing." + "description": "Outcome of this request's document availability check. `last_checked_at` is the successful check time, or null on failure. Failed checks return the last available document list without billing." }, "object": { "const": "company_document_list", @@ -2181,7 +2181,7 @@ }, "is_outdated": { "default": false, - "description": "`true` for a stored register extract older than the company's latest register entry. Request `/documents/download` with `fetch_realtime=true` for a current extract. Always `false` for DK documents.", + "description": "`true` for a stored register extract older than the company's latest register entry. Request `/documents/download` with `fetch_realtime=true` for a current extract. Always `false` for other register documents.", "title": "Is Outdated", "type": "boolean" }, @@ -2278,7 +2278,7 @@ }, "freshness": { "$ref": "#/components/schemas/Freshness", - "description": "How current the financial filings in this response are. `last_checked_at` is the timestamp at which the company's filings were last successfully fetched and parsed; `realtime_fetching_status` reports the outcome of the realtime attempt for this request." + "description": "How current the financial filings in this response are. `last_checked_at` is the timestamp at which the company's filings were last successfully refreshed; `realtime_fetching_status` reports the outcome of the realtime attempt for this request." }, "history": { "$ref": "#/components/schemas/FinancialHistoryBlock", @@ -2324,7 +2324,7 @@ "type": "object" }, "CompanyHistory": { - "description": "Response model for `GET /v1/companies/{eu_id}/history`.\n\nEverything recorded against a company in the chronological register\nextract (Chronologischer Abdruck), grouped by what changed. Timelines are\noldest first; dates are ISO 8601 and people use the same `person` shape\nas the company detail.", + "description": "Chronological register history for a company.\n\nEverything recorded against a company in the chronological register\nextract (Chronologischer Abdruck), grouped by what changed. Timelines are\noldest first; dates are ISO 8601 and people use the same `person` shape\nas the company detail.", "properties": { "articles_of_association": { "description": "Creation and amendments of the constitutional document — Satzung, Gesellschaftsvertrag or Statut depending on legal form.", @@ -2414,14 +2414,14 @@ "type": "null" } ], - "description": "Current legal status derived from the timeline (`active`, `dissolved`, `in_liquidation`, `insolvent`). `null` when the German register history is not available. Swiss companies still receive their legal status from SHAB bankruptcy and Nachlass notices.", + "description": "Current legal status derived from the timeline (`active`, `dissolved`, `in_liquidation`, `insolvent`). `null` when the German register history is not available. Swiss companies still receive their legal status from the Swiss Official Gazette of Commerce (SHAB) bankruptcy and Nachlass notices.", "examples": [ "active" ], "title": "Legal Status" }, "legal_status_events": { - "description": "Insolvency, Auflösung and Liquidation events, newest first, merged and deduplicated across the source registries. Swiss companies still receive their legal-status timeline from SHAB bankruptcy and Nachlass notices when register history is not available. Empty for a company that has never been in insolvency or liquidation.", + "description": "Insolvency, Auflösung and Liquidation events, newest first, merged and deduplicated across the source registries. Swiss companies still receive their legal-status timeline from the Swiss Official Gazette of Commerce (SHAB) bankruptcy and Nachlass notices when register history is not available. Empty for a company that has never been in insolvency or liquidation.", "items": { "$ref": "#/components/schemas/LegalStatusEvent" }, @@ -2953,7 +2953,7 @@ "type": "object" }, "FeedEvent": { - "description": "A company event. A reparse can resurface an id; deduplicate on `id`.", + "description": "A company event. The same event may appear more than once; deduplicate on `id`.", "properties": { "company": { "$ref": "#/components/schemas/FeedCompany" @@ -3170,7 +3170,7 @@ "type": "null" } ], - "description": "Top-level balance-sheet positions (Anlagevermögen, Sachanlagen, Umlaufvermögen, Eigenkapital, Rückstellungen, Verbindlichkeiten, …). `null` when no balance sheet was parsed for this year." + "description": "Top-level balance-sheet positions (Anlagevermögen, Sachanlagen, Umlaufvermögen, Eigenkapital, Rückstellungen, Verbindlichkeiten, …). `null` when no balance sheet is available for this year." }, "balance_sheet_total": { "anyOf": [ @@ -3763,7 +3763,7 @@ "type": "null" } ], - "description": "Which register sources backed this assessment: `cd` (chronological extract), `si` (structured snapshot) or `cd+si`.", + "description": "Register information supporting this assessment.", "title": "Evidence Source" }, "family_confidence": { @@ -4164,7 +4164,7 @@ "title": "Reason" }, "status": { - "description": "`available`: the chronological register extract was parsed and the timelines below are populated. `not_available`: no extract exists for this company — the Swiss commercial register publishes none, Austrian history is not available yet, and for a German company none has been retrieved yet (`fetch_realtime=true` retrieves it). The register timelines are then empty and the call is not charged. Swiss companies still receive their legal-status timeline from SHAB.", + "description": "`available`: the chronological register history is available and the timelines below are populated. `not_available`: no extract exists for this company — the Swiss commercial register publishes none, Austrian history is not available yet, and for a German company none has been retrieved yet (`fetch_realtime=true` retrieves it). The register timelines are then empty and the call is not charged. Swiss companies still receive their legal-status timeline from the Swiss Official Gazette of Commerce (SHAB).", "enum": [ "available", "not_available" @@ -4410,7 +4410,7 @@ "type": "string" }, "InsolvencyBlock": { - "description": "Insolvency / Auflösung / Liquidation snapshot.\n\nPresent whenever any legal-status event is on record — a voluntary\nliquidation reports ``has_insolvency=false`` with a ``current_status``\nof ``in_liquidation``.", + "description": "Insolvency / Auflösung / Liquidation snapshot.\n\nPresent whenever any legal-status event is on record — a voluntary\nliquidation reports `has_insolvency=false` with a `current_status`\nof `in_liquidation`.", "properties": { "case_number": { "anyOf": [ @@ -4590,7 +4590,7 @@ "type": "object" }, "LegalStatus": { - "description": "Company lifecycle buckets for the search filter.\n\nDisjoint and exhaustive — every company in the index is in exactly one,\nand `deleted` outranks the rest (a deleted company that was also in\nliquidation counts as deleted, matching the company detail page).", + "description": "Company lifecycle buckets for the search filter.\n\nDisjoint and exhaustive — every company is in exactly one,\nand `deleted` outranks the rest (a deleted company that was also in\nliquidation counts as deleted, matching the company detail page).", "enum": [ "active", "in_liquidation", @@ -4617,7 +4617,7 @@ "title": "Case Number" }, "category": { - "description": "Event type, in the shared German vocabulary: `Sicherungsmaßnahmen`, `Eröffnungen`, `Abweisungen mangels Masse`, `Aufhebung Sicherungsmaßnahmen`, `Aufhebung des Verfahrens`, `Auflösung durch Gesellschafterbeschluss`, `Auflösung mit Fortführung`, `Auflösung`. Swiss Handelsamtsblatt / SHAB notices for Swiss companies: `Konkurseröffnung`, `Einstellung mangels Aktiven`, `Schluss des Konkursverfahrens`, `Widerruf des Konkurses`, `Provisorische Nachlassstundung`, `Definitive Nachlassstundung`, `Aufhebung der Nachlassstundung`, `Bestätigung des Nachlassvertrages`, `Nachlassvertrag mit Vermögensabtretung`.", + "description": "Event type, in the shared German vocabulary: `Sicherungsmaßnahmen`, `Eröffnungen`, `Abweisungen mangels Masse`, `Aufhebung Sicherungsmaßnahmen`, `Aufhebung des Verfahrens`, `Auflösung durch Gesellschafterbeschluss`, `Auflösung mit Fortführung`, `Auflösung`. Swiss Official Gazette of Commerce (SHAB) notices for Swiss companies: `Konkurseröffnung`, `Einstellung mangels Aktiven`, `Schluss des Konkursverfahrens`, `Widerruf des Konkurses`, `Provisorische Nachlassstundung`, `Definitive Nachlassstundung`, `Aufhebung der Nachlassstundung`, `Bestätigung des Nachlassvertrages`, `Nachlassvertrag mit Vermögensabtretung`.", "examples": [ "Auflösung durch Gesellschafterbeschluss" ], @@ -4690,7 +4690,7 @@ "type": "null" } ], - "description": "Register entry date (SI / CD), portal publication date, or the SHAB publication date for `shab`. `null` when the source records no date.", + "description": "Register entry date, portal publication date, or the Swiss Official Gazette of Commerce (SHAB) publication date for `shab`. `null` when the source records no date.", "title": "Event Date" }, "ex_officio": { @@ -4726,7 +4726,7 @@ "type": "boolean" }, "sources": { - "description": "Registries that recorded this event. `si` — Strukturierte Inhalte, `cd` — Chronologisches Datenblatt, `portal` — Insolvenzbekanntmachungen, `shab` — Schweizerisches Handelsamtsblatt (SOGC/FOSC), `firmenbuch` — Austrian Firmenbuch.", + "description": "Register or official publication venue recording this event: the commercial register, Insolvenzbekanntmachungen, the Swiss Official Gazette of Commerce (SHAB), or the Austrian Firmenbuch.", "items": { "enum": [ "si", @@ -5125,7 +5125,7 @@ "type": "null" } ], - "description": "firmendata-internal role code.", + "description": "Stable machine-readable role code.", "title": "Role Code" } }, @@ -5133,7 +5133,7 @@ "type": "object" }, "Membership": { - "description": "External-registry memberships used as a company filter.\n\nEach value renders to an ``EXISTS`` predicate against a side table\nthat carries ``eu_id`` back to ``company.eu_id``. The repository\nOR-merges the predicates when multiple values are supplied.", + "description": "External-registry memberships used as a company filter.\n\nSelecting multiple values matches companies with any of the selected\nmemberships.", "enum": [ "Steuerberaterkammer", "Wirtschaftsprueferkammer", @@ -5233,7 +5233,7 @@ "type": "object" }, "NotificationCadence": { - "description": "How often you wish to receive notifications.\n\nA notification is only delivered when there is an actual update\nsince the previous successful delivery — no update means no\nnotification. For ``one_time`` subscriptions, this is the retry\nspacing while we wait for the data to become available.\n\n``immediately`` delivers an update as soon as it is detected —\nservice-level objective: within 1 hour of publication.\n``daily`` / ``weekly`` / ``monthly`` batch the check at the named\ncadence and notify only on actual changes.", + "description": "How often you wish to receive notifications.\n\nA notification is only delivered when there is an actual update\nsince the previous successful delivery — no update means no\nnotification. For `one_time` subscriptions, this is the retry\nspacing while we wait for the data to become available.\n\n`immediately` delivers an update as soon as it is detected —\nservice-level objective: within 1 hour of publication.\n`daily` / `weekly` / `monthly` batch the check at the named\ncadence and notify only on actual changes.", "enum": [ "immediately", "daily", @@ -5244,7 +5244,7 @@ "type": "string" }, "NotificationLanguage": { - "description": "Display language for human-targeted notifications (currently\njust email subject + body). Webhook payloads carry both languages\nin a bilingual ``summary`` block regardless of this setting.", + "description": "Display language for human-targeted notifications (currently\njust email subject + body). Webhook payloads carry both languages\nin a bilingual `summary` block regardless of this setting.", "enum": [ "en", "de" @@ -5253,7 +5253,7 @@ "type": "string" }, "NotificationType": { - "description": "Transport for the notification.\n\n* ``webhook``: HTTP POST a signed JSON payload to ``webhook_url``.\n* ``email``: Send a bilingual email to ``recipient_email``.\n\nA subscription with ``notification_type = None`` is async (poll-only):\nthe dispatcher performs no outbound delivery, records the change in\n``subscription_events``, and the customer retrieves the rendered\npayload via the events API using the subscription/job id returned at\ncreate time.", + "description": "Transport for the notification.\n\n* `webhook`: HTTP POST a signed JSON payload to `webhook_url`.\n* `email`: Send a bilingual email to `recipient_email`.\n\nWith `notification_type = null`, no outbound notification is sent.\nRetrieve changes through the events API using the subscription/job id\nreturned when the subscription is created.", "enum": [ "webhook", "email" @@ -5669,6 +5669,10 @@ "nullable": true, "type": "string" }, + "message": { + "description": "Same as `detail`; provided for HTTP clients that read a `message` field.", + "type": "string" + }, "request_id": { "description": "Same value as the `X-Request-Id` response header: the one you sent, or a server-generated id. Quote it when contacting support.", "type": "string" @@ -5694,6 +5698,7 @@ "type", "title", "status", + "message", "request_id" ], "title": "Problem", @@ -6253,7 +6258,7 @@ }, "legal_status": { "default": "active", - "description": "Lifecycle status, resolved from the merged Handelsregister (SI + CD) and Insolvenzbekanntmachungen timelines. Swiss companies' status comes from SHAB bankruptcy and Nachlass notices. Same vocabulary as `CompanyDetail.legal_status` and the `legal_status` search filter.", + "description": "Company lifecycle status according to the commercial register and insolvency notices, including Swiss Official Gazette of Commerce (SHAB) bankruptcy and composition notices. Same vocabulary as `CompanyDetail.legal_status` and the `legal_status` search filter.", "enum": [ "active", "in_liquidation", @@ -6648,7 +6653,7 @@ }, "status": { "default": "not_filed", - "description": "Coverage status. `available`: cap table parsed and returned; `not_applicable`: legal form does not file a Gesellschafterliste, or the company is Austrian (no shareholder data yet); `not_filed`: filing expected but not yet received; `token_limit_reached`: filing too large to process.", + "description": "Coverage status. `available`: cap table available and returned; `not_applicable`: legal form does not file a Gesellschafterliste, or the company is Austrian (no shareholder data yet); `not_filed`: filing expected but not yet received; `token_limit_reached`: filing too large to process.", "enum": [ "available", "not_applicable", @@ -6663,7 +6668,7 @@ "type": "object" }, "ShareholdersReport": { - "description": "Response model for `GET /v1/companies/{eu_id}/shareholders`.", + "description": "Current shareholders and their shares in the company.", "properties": { "as_of_snapshot": { "anyOf": [ @@ -6687,7 +6692,7 @@ }, "freshness": { "$ref": "#/components/schemas/Freshness", - "description": "How current the cap-table snapshot in this response is. `last_checked_at` is the timestamp at which the underlying Liste der Gesellschafter (or Musterprotokoll fallback) was last successfully ingested for this company; `null` when `coverage.status != \"available\"`. `realtime_fetching_status` reports the outcome of the realtime attempt for this request." + "description": "How current the cap-table snapshot in this response is. `last_checked_at` is the timestamp at which the underlying Liste der Gesellschafter (or Musterprotokoll fallback) was last successfully retrieved for this company; `null` when `coverage.status != \"available\"`. `realtime_fetching_status` reports the outcome of the realtime attempt for this request." }, "object": { "const": "shareholders_report", @@ -6776,7 +6781,7 @@ "type": "string" }, "Subscription": { - "description": "A webhook or email subscription, with the most recent delivery\nattempt embedded.\n\nThe signing secret is **never** returned here; only\n:class:`SubscriptionCreated` carries it, and only once.", + "description": "A webhook or email subscription, with the most recent delivery\nattempt embedded.\n\nThe signing secret is **never** returned here; only\n`SubscriptionCreated` carries it, and only once.", "properties": { "cadence": { "anyOf": [ @@ -6787,7 +6792,7 @@ "type": "null" } ], - "description": "How often you wish to receive notifications. A delivery is only made when the underlying data has actually changed since `latest_succeeded_publish_date` — no change means no notification. `immediately` delivers an update as soon as it is detected, with a service-level objective of within 1 hour of publication. `daily` / `weekly` / `monthly` batch the check at the named cadence. `null` only for legacy subscriptions whose stored cadence predates this enum." + "description": "How often you wish to receive notifications. A delivery is only made when the underlying data has actually changed since `latest_succeeded_publish_date` — no change means no notification. `immediately` delivers an update as soon as it is detected, with a service-level objective of within 1 hour of publication. `daily` / `weekly` / `monthly` batch the check at the named cadence. `null` only for legacy subscriptions without a named cadence; use `cadence_seconds` instead." }, "cadence_seconds": { "description": "Canonical numeric form of `cadence`.", @@ -6854,7 +6859,7 @@ }, "mode": { "$ref": "#/components/schemas/SubscriptionMode", - "description": "`recurring` keeps delivering forever at the configured `cadence`. `one_time` keeps polling at the configured `cadence` until the first successful delivery, then stops. The row stays around as a record — `DELETE` it when you're done." + "description": "`recurring` keeps delivering forever at the configured `cadence`. `one_time` keeps polling at the configured `cadence` until the first successful delivery, then stops. The subscription remains available as a record — `DELETE` it when you're done." }, "notification_type": { "anyOf": [ @@ -6972,7 +6977,7 @@ "type": "null" } ], - "description": "Delivery transport. **Default: `null`** — async (poll-only).\n\n- `null` — no outbound delivery; the dispatcher records each change as a `subscription_event` and the customer retrieves the rendered payload by polling `GET /v1/subscriptions/{id}/events` using the subscription/job id this request returns. Must omit both `webhook_url` and `recipient_email`.\n- `webhook` — POST a signed JSON body to `webhook_url`. Requires `webhook_url`; rejects `recipient_email`.\n- `email` — send a bilingual notification email to `recipient_email`. Requires `recipient_email`; rejects `webhook_url`." + "description": "Delivery transport. **Default: `null`** — async (poll-only).\n\n- `null` — no outbound delivery; each change is available as a `subscription_event` and the customer retrieves the rendered payload by polling `GET /v1/subscriptions/{id}/events` using the subscription/job id this request returns. Must omit both `webhook_url` and `recipient_email`.\n- `webhook` — POST a signed JSON body to `webhook_url`. Requires `webhook_url`; rejects `recipient_email`.\n- `email` — send a bilingual notification email to `recipient_email`. Requires `recipient_email`; rejects `webhook_url`." }, "recipient_email": { "anyOf": [ @@ -7033,7 +7038,7 @@ "type": "object" }, "SubscriptionCreated": { - "description": "Response shape for ``POST /v1/subscriptions``. Identical to\n:class:`Subscription`. For webhook subscriptions it additionally\ncarries the plaintext signing secret — only available here, never\nreturned by any other endpoint. For email subscriptions\n``webhook_secret`` is ``null`` (signatures don't apply to email).", + "description": "Newly created subscription. Includes the subscription details and,\nfor webhook subscriptions, additionally\ncarries the plaintext signing secret — only available here, never\nreturned by any other endpoint. For email subscriptions\n`webhook_secret` is `null` (signatures don't apply to email).", "properties": { "cadence": { "anyOf": [ @@ -7044,7 +7049,7 @@ "type": "null" } ], - "description": "How often you wish to receive notifications. A delivery is only made when the underlying data has actually changed since `latest_succeeded_publish_date` — no change means no notification. `immediately` delivers an update as soon as it is detected, with a service-level objective of within 1 hour of publication. `daily` / `weekly` / `monthly` batch the check at the named cadence. `null` only for legacy subscriptions whose stored cadence predates this enum." + "description": "How often you wish to receive notifications. A delivery is only made when the underlying data has actually changed since `latest_succeeded_publish_date` — no change means no notification. `immediately` delivers an update as soon as it is detected, with a service-level objective of within 1 hour of publication. `daily` / `weekly` / `monthly` batch the check at the named cadence. `null` only for legacy subscriptions without a named cadence; use `cadence_seconds` instead." }, "cadence_seconds": { "description": "Canonical numeric form of `cadence`.", @@ -7111,7 +7116,7 @@ }, "mode": { "$ref": "#/components/schemas/SubscriptionMode", - "description": "`recurring` keeps delivering forever at the configured `cadence`. `one_time` keeps polling at the configured `cadence` until the first successful delivery, then stops. The row stays around as a record — `DELETE` it when you're done." + "description": "`recurring` keeps delivering forever at the configured `cadence`. `one_time` keeps polling at the configured `cadence` until the first successful delivery, then stops. The subscription remains available as a record — `DELETE` it when you're done." }, "notification_type": { "anyOf": [ @@ -7206,7 +7211,7 @@ "type": "object" }, "SubscriptionEvent": { - "description": "Full event record with the exact bytes firmendata sent on the\nwire. Returned by the historical event endpoints — heavier than\n:class:`SubscriptionEventStatus`, which is the trimmed status block\nembedded in subscription detail responses.\n\nFor email rows and historical rows that predate the request-body\ncolumn migration, every ``request_*`` field is ``null``.", + "description": "Full event record with the exact bytes firmendata sent on the\nwire. Returned by the historical event endpoints — heavier than\n`SubscriptionEventStatus`, the shorter status block embedded in\nsubscription detail responses.\n\nFor email events and older events without recorded request details,\nevery `request_*` field is `null`.", "properties": { "change_detected": { "description": "True iff the latest check produced a publish date strictly newer than the previously delivered one.", @@ -7348,7 +7353,7 @@ "type": "null" } ], - "description": "The exact JSON body firmendata POSTed to your endpoint. Null for email events and for events recorded before the request body was persisted (rows older than 2026-05-02). Re-verify the signature with: `hmac_sha256(secret, f'{request_timestamp}.{json.dumps(request_body, separators=(\",\",\":\"))}')`.", + "description": "The exact JSON body firmendata POSTed to your endpoint. `null` for email events and older events without a recorded request body. Re-verify the signature with: `hmac_sha256(secret, f'{request_timestamp}.{json.dumps(request_body, separators=(\",\",\":\"))}')`.", "title": "Request Body" }, "request_signature": { @@ -7396,8 +7401,8 @@ "type": "null" } ], - "description": "Compact, customer-safe description of a failed data check. One sentence — names the company id and the data slice in plain language; never echoes internal class names. Examples: `\"No shareholder list available for DEF1103R.HRB279792B on Handelsregister.\"`, `\"Handelsregister was temporarily unreachable while checking the articles of association for DEF1103R.HRB279792B.\"`. The dispatcher re-attempts every failed scrape on the configured `cadence`, so most messages don't repeat that — the only exception is `scrape_failed_company_not_found`, which is effectively terminal until the company reappears in the registry. `null` for successful or non-failure events.", - "title": "Scrape Error Message" + "description": "Explanation of a failed data check, or `null` for successful or non-failure events. Failed checks are retried on the configured `cadence`. For `scrape_failed_company_not_found`, the company must reappear in the register before data can be retrieved.", + "title": "Data check error message" }, "started_at": { "description": "UTC timestamp the attempt began.", @@ -7444,7 +7449,7 @@ "type": "object" }, "SubscriptionEventResendResponse": { - "description": "Response shape for ``POST /v1/subscriptions/events/{event_id}/resend``.", + "description": "Result of resending a subscription event.", "properties": { "duration_ms": { "description": "Wall-clock time of the resend in milliseconds.", @@ -7465,7 +7470,7 @@ }, "event": { "$ref": "#/components/schemas/SubscriptionEvent", - "description": "The new event row recording this resend. Has its own fresh `event_id`. The receiver sees that same id in the body and in the `X-Firmendata-Delivery-Id` header." + "description": "The new event recording this resend. Has its own fresh `event_id`. The receiver sees that same id in the body and in the `X-Firmendata-Delivery-Id` header." }, "http_status": { "anyOf": [ @@ -7619,8 +7624,8 @@ "type": "null" } ], - "description": "Compact, customer-safe description of a failed data check. One sentence — names the company id and the data slice in plain language; never echoes internal class names. Examples: `\"No shareholder list available for DEF1103R.HRB279792B on Handelsregister.\"`, `\"Handelsregister was temporarily unreachable while checking the articles of association for DEF1103R.HRB279792B.\"`. The dispatcher re-attempts every failed scrape on the configured `cadence`, so most messages don't repeat that — the only exception is `scrape_failed_company_not_found`, which is effectively terminal until the company reappears in the registry. `null` for successful or non-failure events.", - "title": "Scrape Error Message" + "description": "Explanation of a failed data check, or `null` for successful or non-failure events. Failed checks are retried on the configured `cadence`. For `scrape_failed_company_not_found`, the company must reappear in the register before data can be retrieved.", + "title": "Data check error message" }, "started_at": { "description": "UTC timestamp the attempt began.", @@ -7675,7 +7680,7 @@ "type": "string" }, "SubscriptionTestRequest": { - "description": "Inputs for ``POST /v1/subscriptions/test``.\n\nSame discriminator rules as the create payload: webhook\nsubscriptions need ``webhook_url``, email subscriptions need\n``recipient_email``. Sending the wrong combination returns ``422``.", + "description": "Inputs for `POST /v1/subscriptions/test`.\n\nSame discriminator rules as the create payload: webhook\nsubscriptions need `webhook_url`, email subscriptions need\n`recipient_email`. Sending the wrong combination returns `422`.", "properties": { "delivery_scenario": { "$ref": "#/components/schemas/WebhookTestDeliveryScenario", @@ -7755,7 +7760,7 @@ "type": "object" }, "SubscriptionTestResponse": { - "description": "Outputs for ``POST /v1/subscriptions/test``.\n\nTransport-shaped: the webhook fields populate when the request was\na webhook test, the email fields populate when it was an email\ntest. The common ``receiver_*`` block describes the outcome\nregardless of transport.", + "description": "Outputs for `POST /v1/subscriptions/test`.\n\nTransport-shaped: the webhook fields populate when the request was\na webhook test, the email fields populate when it was an email\ntest. The common `receiver_*` block describes the outcome\nregardless of transport.", "properties": { "attempts": { "anyOf": [ @@ -7971,7 +7976,7 @@ "type": "object" }, "SubscriptionType": { - "description": "Slice of company data a subscription tracks. Two flavours:\n\n- **Parsed-data** types — `details`, `history`, `shareholders`, `ubo`. The webhook body's `data` block carries a publish-date timestamp and `links` to the v1 endpoints that serve the parsed JSON.\n- **Document** types — every `doc_*` value. The webhook body's `data.download.url` is a 7-day presigned URL to the PDF, so the customer can fetch the file directly without a follow-up API call. After expiry, `GET /v1/companies/{eu_id}/documents/download` mints a fresh URL.\n\n**Coverage is not uniform across types.** German company law lets the smallest GmbH / UG (haftungsbeschränkt) replace the Articles of Association (Gesellschaftsvertrag / Satzung / Statut — the name varies by legal form) and the Liste der Gesellschafter with a single **Musterprotokoll** (model protocol) at founding. firmendata handles this asymmetrically by intent:\n\n- `shareholders` **auto-falls-back** to the Musterprotokoll when no Liste der Gesellschafter is filed — pick this for cap-table tracking regardless of legal form.\n- `doc_articles_of_association` does **not** fall back — pick `doc_model_protocol` when you need the founding deed of a simplified-procedure company.\n- `doc_shareholder_list` does **not** fall back — pick `doc_model_protocol` for Musterprotokoll-only companies, or `shareholders` for parsed cap-table data that works for any GmbH/UG.\n- `doc_*` subscriptions only fire for companies that actually filed the matching document; mismatches return `scrape_failed_file_not_found` until the document appears (use `mode=one_time` if you're waiting for a one-off filing).\n\n| Value | What it tracks |\n|---|---|\n| `details` | Current company snapshot — identity, registered address, current board, share capital, and lifecycle status. The webhook body's `data.links.company_detail` resolves to `GET /v1/companies/{eu_id}` for the full parsed JSON. Available for every Handelsregister-registered company. |\n| `history` | Chronological register history — every recorded change to company name (firma), seat (Sitz), capital, board, business purpose, etc. The webhook body's `data.links.company_history` resolves to `GET /v1/companies/{eu_id}/history` for the full parsed timeline. Available for every Handelsregister-registered company. |\n| `shareholders` | Cap table — primarily from the **Liste der Gesellschafter** (list of shareholders), filed by GmbH and UG (haftungsbeschränkt). **Auto-fallback to Musterprotokoll:** companies founded via the simplified procedure under §2 Abs. 1a GmbHG do not file a separate Liste — their **Musterprotokoll** (model protocol) names the founders and their share allocations directly. firmendata handles this transparently: when no Liste has been filed, the `shareholders` subscription derives the cap table from the Musterprotokoll instead. So `shareholders` is the right subscription for cap-table updates regardless of which document the company filed. The webhook body links to `GET /v1/companies/{eu_id}/shareholders` for the parsed cap table. |\n| `ubo` | Beneficial owners (**wirtschaftlich Berechtigte**) — natural persons who ultimately own or control the company, derived by walking the Gesellschafter graph. Each dispatch refreshes the cap-table document for **every** German GmbH/UG in the ownership chain (root and parents), not just the root, and fires when **any** chain member's Liste der Gesellschafter (or Musterprotokoll fallback) gets a newer publish date. The webhook body carries the full UBO report (graph, `beneficial_owners`, `potential_beneficial_owners`, `coverage` with `reason`) plus a per-node `chain` breakdown (`eu_id`, `depth`, refresh `status`, latest `publish_date`). `coverage.status=\"partial\"` means one or more unresolved branches carry effective share above 25 % at the root and could hide a UBO — `potential_beneficial_owners` lists those entities (foreign holdings, unlisted parents, depth-capped branches) with their summed effective share so KYC consumers can decide whether to escalate. `coverage.status=\"available\"` with `has_unresolved_branches=true` means unresolved branches exist but are immaterial (combined share below the threshold or isolated by sub-threshold control links). Transient per-node failures (e.g. `documents_temporarily_unavailable`, `platform_unavailable`) defer the dispatch — `latest_succeeded_publish_date` is not advanced and the next cadence retries; chain members that succeeded today are dedup'd at the registry layer so retries only re-hit the failed nodes' documents. Deterministic per-node outcomes (e.g. `file_unavailable`, `skipped_not_applicable` for non-GmbH chain members) are surfaced in the chain breakdown but do **not** block delivery — the rest of the chain still ships. |\n| `doc_articles_of_association` | **Articles of association** document — Germany's constitutional company document, called **Gesellschaftsvertrag** (GmbH, KG, OHG, GbR), **Satzung** (AG, KGaA, eG, Verein), or **Statut** (some specific forms) depending on legal form. All three are filed under the same registry slot. Webhook body's `data.download.url` is a 7-day presigned URL to the PDF — the customer can fetch the file directly without a follow-up API call. After the URL expires, `GET /v1/companies/{eu_id}/documents/download?file_type=articles_of_association` mints a fresh one. **Not all companies have one.** Companies founded via the simplified procedure under §2 Abs. 1a GmbHG file a **Musterprotokoll** in its place — subscribe to `doc_model_protocol` for those. |\n| `doc_company_registration` | **Initial company registration filing (Anmeldung zur Eintragung)** — the notarial document submitted to register the company. Webhook body carries a 7-day presigned download URL (`data.download.url`). Available for any newly-registered company; fires when a new Anmeldung is filed. |\n| `doc_model_protocol` | **Model protocol document (Musterprotokoll)** — the simplified notarial founding deed for small GmbH and UG (haftungsbeschränkt) companies under §2 Abs. 1a GmbHG. Legally substitutes for **both** the Articles of Association and the Liste der Gesellschafter for these companies. The Musterprotokoll itself names the founders, their share allocations, and the basic company terms in a single document. Webhook body carries a 7-day presigned download URL (`data.download.url`). Only companies founded via the simplified procedure have one; for everyone else this subscription reports `scrape_failed_file_not_found`. For **cap-table changes** — regardless of which document was filed — subscribe to `shareholders` instead (it auto-falls-back to the Musterprotokoll when no Liste has been filed). |\n| `doc_shareholder_list` | **Liste der Gesellschafter** document — the standalone shareholder-list filing. Webhook body carries a 7-day presigned download URL (`data.download.url`). Distinct from the parsed `shareholders` subscription: this tracks the **document itself** with no Musterprotokoll fallback, so a Musterprotokoll-only GmbH (founded under §2 Abs. 1a GmbHG) reports `scrape_failed_file_not_found` until they file a real Liste. For parsed cap-table updates that work for any GmbH/UG, subscribe to `shareholders` instead. |", + "description": "Slice of company data a subscription tracks. Two flavours:\n\n- **Parsed-data** types — `details`, `history`, `shareholders`, `ubo`. The webhook body's `data` block carries a publish-date timestamp and `links` to the v1 endpoints that serve the parsed JSON.\n- **Document** types — every `doc_*` value. The webhook body's `data.download.url` is a 7-day presigned URL to the PDF, so the customer can fetch the file directly without a follow-up API call. After expiry, `GET /v1/companies/{eu_id}/documents/download` mints a fresh URL.\n\n**Coverage is not uniform across types.** German company law lets the smallest GmbH / UG (haftungsbeschränkt) replace the Articles of Association (Gesellschaftsvertrag / Satzung / Statut — the name varies by legal form) and the Liste der Gesellschafter with a single **Musterprotokoll** (model protocol) at founding. firmendata handles this asymmetrically by intent:\n\n- `shareholders` **auto-falls-back** to the Musterprotokoll when no Liste der Gesellschafter is filed — pick this for cap-table tracking regardless of legal form.\n- `doc_articles_of_association` does **not** fall back — pick `doc_model_protocol` when you need the founding deed of a simplified-procedure company.\n- `doc_shareholder_list` does **not** fall back — pick `doc_model_protocol` for Musterprotokoll-only companies, or `shareholders` for parsed cap-table data that works for any GmbH/UG.\n- `doc_*` subscriptions only fire for companies that actually filed the matching document; mismatches return `scrape_failed_file_not_found` until the document appears (use `mode=one_time` if you're waiting for a one-off filing).\n\n| Value | What it tracks |\n|---|---|\n| `details` | Current company snapshot — identity, registered address, current board, share capital, and lifecycle status. The webhook body's `data.links.company_detail` resolves to `GET /v1/companies/{eu_id}` for the full parsed JSON. Available for every Handelsregister-registered company. |\n| `history` | Chronological register history — every recorded change to company name (firma), seat (Sitz), capital, board, business purpose, etc. The webhook body's `data.links.company_history` resolves to `GET /v1/companies/{eu_id}/history` for the full parsed timeline. Available for every Handelsregister-registered company. |\n| `shareholders` | Cap table — primarily from the **Liste der Gesellschafter** (list of shareholders), filed by GmbH and UG (haftungsbeschränkt). **Auto-fallback to Musterprotokoll:** companies founded via the simplified procedure under §2 Abs. 1a GmbHG do not file a separate Liste — their **Musterprotokoll** (model protocol) names the founders and their share allocations directly. firmendata handles this transparently: when no Liste has been filed, the `shareholders` subscription derives the cap table from the Musterprotokoll instead. So `shareholders` is the right subscription for cap-table updates regardless of which document the company filed. The webhook body links to `GET /v1/companies/{eu_id}/shareholders` for the parsed cap table. |\n| `ubo` | Beneficial owners (**wirtschaftlich Berechtigte**) — natural persons who ultimately own or control the company, derived by walking the Gesellschafter graph. Each dispatch refreshes the cap-table document for **every** German GmbH/UG in the ownership chain (root and parents), not just the root, and fires when **any** chain member's Liste der Gesellschafter (or Musterprotokoll fallback) gets a newer publish date. The webhook body carries the full UBO report (graph, `beneficial_owners`, `potential_beneficial_owners`, `coverage` with `reason`) plus a per-node `chain` breakdown (`eu_id`, `depth`, refresh `status`, latest `publish_date`). `coverage.status=\"partial\"` means one or more unresolved branches carry effective share above 25 % at the root and could hide a UBO — `potential_beneficial_owners` lists those entities (foreign holdings, unlisted parents, depth-capped branches) with their summed effective share so KYC consumers can decide whether to escalate. `coverage.status=\"available\"` with `has_unresolved_branches=true` means unresolved branches exist but are immaterial (combined share below the threshold or isolated by sub-threshold control links). Transient per-node failures (e.g. `documents_temporarily_unavailable`, `platform_unavailable`) delay notification until the next successful check; the next cadence retries. Other per-node outcomes (e.g. `file_unavailable`, `skipped_not_applicable` for non-GmbH chain members) are surfaced in the chain breakdown but do **not** block delivery — the rest of the chain still ships. |\n| `doc_articles_of_association` | **Articles of association** document — Germany's constitutional company document, called **Gesellschaftsvertrag** (GmbH, KG, OHG, GbR), **Satzung** (AG, KGaA, eG, Verein), or **Statut** (some specific forms) depending on legal form. Each is the constitutional document for its legal form. Webhook body's `data.download.url` is a 7-day presigned URL to the PDF — the customer can fetch the file directly without a follow-up API call. After the URL expires, `GET /v1/companies/{eu_id}/documents/download?file_type=articles_of_association` mints a fresh one. **Not all companies have one.** Companies founded via the simplified procedure under §2 Abs. 1a GmbHG file a **Musterprotokoll** in its place — subscribe to `doc_model_protocol` for those. |\n| `doc_company_registration` | **Initial company registration filing (Anmeldung zur Eintragung)** — the notarial document submitted to register the company. Webhook body carries a 7-day presigned download URL (`data.download.url`). Available for any newly-registered company; fires when a new Anmeldung is filed. |\n| `doc_model_protocol` | **Model protocol document (Musterprotokoll)** — the simplified notarial founding deed for small GmbH and UG (haftungsbeschränkt) companies under §2 Abs. 1a GmbHG. Legally substitutes for **both** the Articles of Association and the Liste der Gesellschafter for these companies. The Musterprotokoll itself names the founders, their share allocations, and the basic company terms in a single document. Webhook body carries a 7-day presigned download URL (`data.download.url`). Only companies founded via the simplified procedure have one; for everyone else this subscription reports `scrape_failed_file_not_found`. For **cap-table changes** — regardless of which document was filed — subscribe to `shareholders` instead (it auto-falls-back to the Musterprotokoll when no Liste has been filed). |\n| `doc_shareholder_list` | **Liste der Gesellschafter** document — the standalone shareholder-list filing. Webhook body carries a 7-day presigned download URL (`data.download.url`). Distinct from the parsed `shareholders` subscription: this tracks the **document itself** with no Musterprotokoll fallback, so a Musterprotokoll-only GmbH (founded under §2 Abs. 1a GmbHG) reports `scrape_failed_file_not_found` until they file a real Liste. For parsed cap-table updates that work for any GmbH/UG, subscribe to `shareholders` instead. |", "enum": [ "details", "history", @@ -7988,8 +7993,8 @@ "Current company snapshot — identity, registered address, current board, share capital, and lifecycle status. The webhook body's `data.links.company_detail` resolves to `GET /v1/companies/{eu_id}` for the full parsed JSON. Available for every Handelsregister-registered company.", "Chronological register history — every recorded change to company name (firma), seat (Sitz), capital, board, business purpose, etc. The webhook body's `data.links.company_history` resolves to `GET /v1/companies/{eu_id}/history` for the full parsed timeline. Available for every Handelsregister-registered company.", "Cap table — primarily from the **Liste der Gesellschafter** (list of shareholders), filed by GmbH and UG (haftungsbeschränkt). **Auto-fallback to Musterprotokoll:** companies founded via the simplified procedure under §2 Abs. 1a GmbHG do not file a separate Liste — their **Musterprotokoll** (model protocol) names the founders and their share allocations directly. firmendata handles this transparently: when no Liste has been filed, the `shareholders` subscription derives the cap table from the Musterprotokoll instead. So `shareholders` is the right subscription for cap-table updates regardless of which document the company filed. The webhook body links to `GET /v1/companies/{eu_id}/shareholders` for the parsed cap table.", - "Beneficial owners (**wirtschaftlich Berechtigte**) — natural persons who ultimately own or control the company, derived by walking the Gesellschafter graph. Each dispatch refreshes the cap-table document for **every** German GmbH/UG in the ownership chain (root and parents), not just the root, and fires when **any** chain member's Liste der Gesellschafter (or Musterprotokoll fallback) gets a newer publish date. The webhook body carries the full UBO report (graph, `beneficial_owners`, `potential_beneficial_owners`, `coverage` with `reason`) plus a per-node `chain` breakdown (`eu_id`, `depth`, refresh `status`, latest `publish_date`).\n\n`coverage.status=\"partial\"` means one or more unresolved branches carry effective share above 25 % at the root and could hide a UBO — `potential_beneficial_owners` lists those entities (foreign holdings, unlisted parents, depth-capped branches) with their summed effective share so KYC consumers can decide whether to escalate. `coverage.status=\"available\"` with `has_unresolved_branches=true` means unresolved branches exist but are immaterial (combined share below the threshold or isolated by sub-threshold control links).\n\nTransient per-node failures (e.g. `documents_temporarily_unavailable`, `platform_unavailable`) defer the dispatch — `latest_succeeded_publish_date` is not advanced and the next cadence retries; chain members that succeeded today are dedup'd at the registry layer so retries only re-hit the failed nodes' documents. Deterministic per-node outcomes (e.g. `file_unavailable`, `skipped_not_applicable` for non-GmbH chain members) are surfaced in the chain breakdown but do **not** block delivery — the rest of the chain still ships.", - "**Articles of association** document — Germany's constitutional company document, called **Gesellschaftsvertrag** (GmbH, KG, OHG, GbR), **Satzung** (AG, KGaA, eG, Verein), or **Statut** (some specific forms) depending on legal form. All three are filed under the same registry slot.\n\nWebhook body's `data.download.url` is a 7-day presigned URL to the PDF — the customer can fetch the file directly without a follow-up API call. After the URL expires, `GET /v1/companies/{eu_id}/documents/download?file_type=articles_of_association` mints a fresh one.\n\n**Not all companies have one.** Companies founded via the simplified procedure under §2 Abs. 1a GmbHG file a **Musterprotokoll** in its place — subscribe to `doc_model_protocol` for those.", + "Beneficial owners (**wirtschaftlich Berechtigte**) — natural persons who ultimately own or control the company, derived by walking the Gesellschafter graph. Each dispatch refreshes the cap-table document for **every** German GmbH/UG in the ownership chain (root and parents), not just the root, and fires when **any** chain member's Liste der Gesellschafter (or Musterprotokoll fallback) gets a newer publish date. The webhook body carries the full UBO report (graph, `beneficial_owners`, `potential_beneficial_owners`, `coverage` with `reason`) plus a per-node `chain` breakdown (`eu_id`, `depth`, refresh `status`, latest `publish_date`).\n\n`coverage.status=\"partial\"` means one or more unresolved branches carry effective share above 25 % at the root and could hide a UBO — `potential_beneficial_owners` lists those entities (foreign holdings, unlisted parents, depth-capped branches) with their summed effective share so KYC consumers can decide whether to escalate. `coverage.status=\"available\"` with `has_unresolved_branches=true` means unresolved branches exist but are immaterial (combined share below the threshold or isolated by sub-threshold control links).\n\nTransient per-node failures (e.g. `documents_temporarily_unavailable`, `platform_unavailable`) delay notification until the next successful check; the next cadence retries. Other per-node outcomes (e.g. `file_unavailable`, `skipped_not_applicable` for non-GmbH chain members) are surfaced in the chain breakdown but do **not** block delivery — the rest of the chain still ships.", + "**Articles of association** document — Germany's constitutional company document, called **Gesellschaftsvertrag** (GmbH, KG, OHG, GbR), **Satzung** (AG, KGaA, eG, Verein), or **Statut** (some specific forms) depending on legal form. Each is the constitutional document for its legal form.\n\nWebhook body's `data.download.url` is a 7-day presigned URL to the PDF — the customer can fetch the file directly without a follow-up API call. After the URL expires, `GET /v1/companies/{eu_id}/documents/download?file_type=articles_of_association` mints a fresh one.\n\n**Not all companies have one.** Companies founded via the simplified procedure under §2 Abs. 1a GmbHG file a **Musterprotokoll** in its place — subscribe to `doc_model_protocol` for those.", "**Initial company registration filing (Anmeldung zur Eintragung)** — the notarial document submitted to register the company. Webhook body carries a 7-day presigned download URL (`data.download.url`). Available for any newly-registered company; fires when a new Anmeldung is filed.", "**Model protocol document (Musterprotokoll)** — the simplified notarial founding deed for small GmbH and UG (haftungsbeschränkt) companies under §2 Abs. 1a GmbHG. Legally substitutes for **both** the Articles of Association and the Liste der Gesellschafter for these companies. The Musterprotokoll itself names the founders, their share allocations, and the basic company terms in a single document.\n\nWebhook body carries a 7-day presigned download URL (`data.download.url`). Only companies founded via the simplified procedure have one; for everyone else this subscription reports `scrape_failed_file_not_found`. For **cap-table changes** — regardless of which document was filed — subscribe to `shareholders` instead (it auto-falls-back to the Musterprotokoll when no Liste has been filed).", "**Liste der Gesellschafter** document — the standalone shareholder-list filing. Webhook body carries a 7-day presigned download URL (`data.download.url`).\n\nDistinct from the parsed `shareholders` subscription: this tracks the **document itself** with no Musterprotokoll fallback, so a Musterprotokoll-only GmbH (founded under §2 Abs. 1a GmbHG) reports `scrape_failed_file_not_found` until they file a real Liste. For parsed cap-table updates that work for any GmbH/UG, subscribe to `shareholders` instead." @@ -7998,15 +8003,15 @@ "Current company snapshot — identity, registered address, current board, share capital, and lifecycle status. The webhook body's `data.links.company_detail` resolves to `GET /v1/companies/{eu_id}` for the full parsed JSON. Available for every Handelsregister-registered company.", "Chronological register history — every recorded change to company name (firma), seat (Sitz), capital, board, business purpose, etc. The webhook body's `data.links.company_history` resolves to `GET /v1/companies/{eu_id}/history` for the full parsed timeline. Available for every Handelsregister-registered company.", "Cap table — primarily from the **Liste der Gesellschafter** (list of shareholders), filed by GmbH and UG (haftungsbeschränkt). **Auto-fallback to Musterprotokoll:** companies founded via the simplified procedure under §2 Abs. 1a GmbHG do not file a separate Liste — their **Musterprotokoll** (model protocol) names the founders and their share allocations directly. firmendata handles this transparently: when no Liste has been filed, the `shareholders` subscription derives the cap table from the Musterprotokoll instead. So `shareholders` is the right subscription for cap-table updates regardless of which document the company filed. The webhook body links to `GET /v1/companies/{eu_id}/shareholders` for the parsed cap table.", - "Beneficial owners (**wirtschaftlich Berechtigte**) — natural persons who ultimately own or control the company, derived by walking the Gesellschafter graph. Each dispatch refreshes the cap-table document for **every** German GmbH/UG in the ownership chain (root and parents), not just the root, and fires when **any** chain member's Liste der Gesellschafter (or Musterprotokoll fallback) gets a newer publish date. The webhook body carries the full UBO report (graph, `beneficial_owners`, `potential_beneficial_owners`, `coverage` with `reason`) plus a per-node `chain` breakdown (`eu_id`, `depth`, refresh `status`, latest `publish_date`).\n\n`coverage.status=\"partial\"` means one or more unresolved branches carry effective share above 25 % at the root and could hide a UBO — `potential_beneficial_owners` lists those entities (foreign holdings, unlisted parents, depth-capped branches) with their summed effective share so KYC consumers can decide whether to escalate. `coverage.status=\"available\"` with `has_unresolved_branches=true` means unresolved branches exist but are immaterial (combined share below the threshold or isolated by sub-threshold control links).\n\nTransient per-node failures (e.g. `documents_temporarily_unavailable`, `platform_unavailable`) defer the dispatch — `latest_succeeded_publish_date` is not advanced and the next cadence retries; chain members that succeeded today are dedup'd at the registry layer so retries only re-hit the failed nodes' documents. Deterministic per-node outcomes (e.g. `file_unavailable`, `skipped_not_applicable` for non-GmbH chain members) are surfaced in the chain breakdown but do **not** block delivery — the rest of the chain still ships.", - "**Articles of association** document — Germany's constitutional company document, called **Gesellschaftsvertrag** (GmbH, KG, OHG, GbR), **Satzung** (AG, KGaA, eG, Verein), or **Statut** (some specific forms) depending on legal form. All three are filed under the same registry slot.\n\nWebhook body's `data.download.url` is a 7-day presigned URL to the PDF — the customer can fetch the file directly without a follow-up API call. After the URL expires, `GET /v1/companies/{eu_id}/documents/download?file_type=articles_of_association` mints a fresh one.\n\n**Not all companies have one.** Companies founded via the simplified procedure under §2 Abs. 1a GmbHG file a **Musterprotokoll** in its place — subscribe to `doc_model_protocol` for those.", + "Beneficial owners (**wirtschaftlich Berechtigte**) — natural persons who ultimately own or control the company, derived by walking the Gesellschafter graph. Each dispatch refreshes the cap-table document for **every** German GmbH/UG in the ownership chain (root and parents), not just the root, and fires when **any** chain member's Liste der Gesellschafter (or Musterprotokoll fallback) gets a newer publish date. The webhook body carries the full UBO report (graph, `beneficial_owners`, `potential_beneficial_owners`, `coverage` with `reason`) plus a per-node `chain` breakdown (`eu_id`, `depth`, refresh `status`, latest `publish_date`).\n\n`coverage.status=\"partial\"` means one or more unresolved branches carry effective share above 25 % at the root and could hide a UBO — `potential_beneficial_owners` lists those entities (foreign holdings, unlisted parents, depth-capped branches) with their summed effective share so KYC consumers can decide whether to escalate. `coverage.status=\"available\"` with `has_unresolved_branches=true` means unresolved branches exist but are immaterial (combined share below the threshold or isolated by sub-threshold control links).\n\nTransient per-node failures (e.g. `documents_temporarily_unavailable`, `platform_unavailable`) delay notification until the next successful check; the next cadence retries. Other per-node outcomes (e.g. `file_unavailable`, `skipped_not_applicable` for non-GmbH chain members) are surfaced in the chain breakdown but do **not** block delivery — the rest of the chain still ships.", + "**Articles of association** document — Germany's constitutional company document, called **Gesellschaftsvertrag** (GmbH, KG, OHG, GbR), **Satzung** (AG, KGaA, eG, Verein), or **Statut** (some specific forms) depending on legal form. Each is the constitutional document for its legal form.\n\nWebhook body's `data.download.url` is a 7-day presigned URL to the PDF — the customer can fetch the file directly without a follow-up API call. After the URL expires, `GET /v1/companies/{eu_id}/documents/download?file_type=articles_of_association` mints a fresh one.\n\n**Not all companies have one.** Companies founded via the simplified procedure under §2 Abs. 1a GmbHG file a **Musterprotokoll** in its place — subscribe to `doc_model_protocol` for those.", "**Initial company registration filing (Anmeldung zur Eintragung)** — the notarial document submitted to register the company. Webhook body carries a 7-day presigned download URL (`data.download.url`). Available for any newly-registered company; fires when a new Anmeldung is filed.", "**Model protocol document (Musterprotokoll)** — the simplified notarial founding deed for small GmbH and UG (haftungsbeschränkt) companies under §2 Abs. 1a GmbHG. Legally substitutes for **both** the Articles of Association and the Liste der Gesellschafter for these companies. The Musterprotokoll itself names the founders, their share allocations, and the basic company terms in a single document.\n\nWebhook body carries a 7-day presigned download URL (`data.download.url`). Only companies founded via the simplified procedure have one; for everyone else this subscription reports `scrape_failed_file_not_found`. For **cap-table changes** — regardless of which document was filed — subscribe to `shareholders` instead (it auto-falls-back to the Musterprotokoll when no Liste has been filed).", "**Liste der Gesellschafter** document — the standalone shareholder-list filing. Webhook body carries a 7-day presigned download URL (`data.download.url`).\n\nDistinct from the parsed `shareholders` subscription: this tracks the **document itself** with no Musterprotokoll fallback, so a Musterprotokoll-only GmbH (founded under §2 Abs. 1a GmbHG) reports `scrape_failed_file_not_found` until they file a real Liste. For parsed cap-table updates that work for any GmbH/UG, subscribe to `shareholders` instead." ] }, "TenderRole": { - "description": "Which side of an EU public-procurement notice a company was on.\n\nOverlapping, not exhaustive — a company can be both, and the large\nmajority of the register is neither. Selecting both values therefore\nmeans \"appears in TED at all\" and is a genuine filter, where selecting\nevery :class:`LegalStatus` is a no-op.", + "description": "Which side of an EU public-procurement notice a company was on.\n\nA company can be a buyer, a winner, both, or neither. Selecting both\nvalues matches companies that appear in TED in either role.", "enum": [ "winner", "buyer" @@ -8053,7 +8058,7 @@ "type": "object" }, "UboEdge": { - "description": "A direct-ownership edge: ``source`` holds ``share_percent`` of ``target``.", + "description": "A direct-ownership edge: `source` holds `share_percent` of `target`.", "properties": { "object": { "const": "ubo_edge", @@ -8228,7 +8233,7 @@ "type": "object" }, "UboReport": { - "description": "Response model for `GET /v1/companies/{eu_id}/ubo`.", + "description": "Ultimate beneficial owners and their ownership relationships.", "properties": { "beneficial_owners": { "description": "Natural persons holding more than 25 % of the root, ordered by effective share descending. Empty when no person crosses the threshold or when coverage is `not_filed`/`not_applicable`. When `coverage.status == \"partial\"`, each listed person's `effective_share_percent` is a **lower bound** — they could also own shares hidden behind unresolved branches.", @@ -8287,7 +8292,7 @@ "type": "object" }, "WebhookTestAttempt": { - "description": "One outbound POST inside a webhook test run.\n\nFor ``delivery_scenario=success`` / ``bad_signature`` /\n``stale_timestamp`` the response carries a single attempt; for\n``retry_delivery`` it carries two with byte-identical headers and\nbody so the customer can see exactly what duplicate delivery looks\nlike on the wire.", + "description": "One outbound POST inside a webhook test run.\n\nFor `delivery_scenario=success` / `bad_signature` /\n`stale_timestamp` the response carries a single attempt; for\n`retry_delivery` it carries two with byte-identical headers and\nbody so the customer can see exactly what duplicate delivery looks\nlike on the wire.", "properties": { "attempt": { "description": "1-indexed attempt number within this test run.", @@ -8369,7 +8374,7 @@ "type": "object" }, "WebhookTestDeliveryScenario": { - "description": "Transport-level behaviour the test endpoint should exhibit.\n\nIndependent of :attr:`SubscriptionTestRequest.scenario`, which picks\nthe *kind* of notification (today only ``change``). This field\npicks how that notification is *delivered* so receivers can\nintegration-test their HMAC, replay-window, and idempotent-dedupe\npaths against firmendata's real wire format.", + "description": "Transport-level behaviour the test endpoint should exhibit.\n\nIndependent of :attr:`SubscriptionTestRequest.scenario`, which picks\nthe *kind* of notification (today only `change`). This field\npicks how that notification is *delivered* so receivers can\nintegration-test their HMAC, replay-window, and idempotent-dedupe\npaths against firmendata's real wire format.", "enum": [ "success", "retry_delivery", @@ -8414,7 +8419,7 @@ }, "verification": { "default": "unverified", - "description": "How the company↔website link was proven: `register` (register code found in the Impressum), `lei`, `linkedin_domain` (LinkedIn declares the domain), `name_unique`, or `unverified`.", + "description": "Evidence associating the website with the company: `register` (register reference in the Impressum), `lei` (LEI record), `linkedin_domain` (the website stated on the company's LinkedIn page), `name_unique` (unique company name), or `unverified`.", "title": "Verification", "type": "string" } @@ -8464,7 +8469,7 @@ "type": "null" } ], - "description": "Classifier confidence for this code, from 0 (none) to 1 (certain), rounded to three decimals. Meaningful for ranking a company's candidate codes against each other; not calibrated as a probability.", + "description": "Confidence score for this code, from 0 (none) to 1 (certain), rounded to three decimals. Meaningful for ranking a company's candidate codes against each other; not calibrated as a probability.", "examples": [ 0.157 ], @@ -8504,19 +8509,19 @@ "email": "info@firmendata.com", "name": "firmendata Support" }, - "description": "Programmatic access to German, Swiss and Austrian company data: register profile, filed\nfinancial statements, company events, and current cap tables.\n\n## Authentication\n\nBearer token in the `Authorization` header. Create and manage your\ntokens in [API Key Settings](https://firmendata.com/de/developer/api-keys).\n\n## Data freshness\n\nEvery company endpoint exposes a `freshness` object so you can see, at\na glance, how stale the response is and which fetch mode produced it:\n\n```jsonc\n\"freshness\": {\n \"object\": \"freshness\",\n \"last_checked_at\": \"2026-04-30T12:14:08Z\",\n \"realtime_fetching_status\": \"realtime_fetching_disabled\"\n}\n```\n\n`last_checked_at` is the UTC timestamp of the last successful refresh\nof the underlying data slice from the German registries. Compare it\nagainst your own staleness tolerance to decide which of the three\nfetch modes below fits the call. `null` means the slice has never been\nsuccessfully fetched for this company.\n\nThere are three ways to consume the data, each with a different\nfreshness guarantee:\n\n1. **Cached read (default).** Returns the most recent data we already\n have on file for the company. Cheapest and fastest. The data may\n be stale — the `freshness.last_checked_at` field tells you exactly\n how stale.\n\n2. **Realtime fetch (`fetch_realtime=true`).** Refreshes the\n underlying slice from the German registries inline before\n responding. Costs more credits and adds upstream latency\n (typically several seconds, sometimes longer). For company endpoints,\n this only affects German (DE) companies; Swiss (CH) and Austrian (AT)\n companies ignore the flag and incur no realtime surcharge. On upstream\n failure, the response falls back to the most recent cached data\n for the company; `freshness.realtime_fetching_status` reports the\n outcome (`success`, `platform_unavailable`,\n `documents_temporarily_unavailable`, `file_unavailable`,\n `company_unavailable`, or `realtime_fetching_disabled`). When we\n have no record of the company at all, the endpoint returns 404\n the same as without `fetch_realtime`.\n\n On `/companies/autocomplete` and `/companies/search` the flag means\n *discovery* rather than refresh: the keyword is searched against the\n live German registers and companies not yet indexed by firmendata\n are added, appearing in the same response. For newly discovered\n companies the register documents and financial publications are then\n fetched in the background at no extra cost, so their full profile is\n available shortly after.\n\n3. **Subscriptions (German companies only).** The only mode that *guarantees* you eventually\n see fresh data: the dispatcher retries failed scrapes on the\n configured cadence and pushes a webhook (or queues an event) when\n something changes. Manage subscriptions via `/subscriptions`.\n\n## Pagination\n\nList endpoints (`/companies/search`, `/lists/{list_id}`, `/subscriptions`,\n`/subscriptions/{id}/events`) use opaque cursor pagination and return the\nsame `pagination` object. Pass the `pagination.next_cursor` value from a\nresponse back unchanged as the `cursor` query parameter to fetch the next\npage. Cursors are signed: one that was edited, or issued by a different\nendpoint, is rejected with `422`. A search cursor also remembers the\nfilters of the request that issued it, so the next page needs only\n`cursor`; repeating the same filters is fine, changing them is a `422`.\nSearch and list cursors are not\nguaranteed to survive a deploy. Feed cursors under `/v1/feeds` persist\nacross deploys and restarts: keep `next_cursor` even after an empty page.\nFeed positions expire after seven days; restart with `since` if expired.\n\n## Rate limits\n\nEvery bearer token is capped at:\n\n- **5 requests per second** (burst protection)\n- **100 requests per minute** (sustained)\n\nBoth windows are enforced via a sliding-window counter, so limits degrade\ngracefully across window boundaries. Every response — success or error —\nincludes headers so clients can self-pace:\n\n- `RateLimit-Limit` — numeric limit of the tighter window currently applying\n- `RateLimit-Remaining` — calls left in that window (never negative)\n- `RateLimit-Reset` — seconds until the window resets\n- `RateLimit-Warning` — present only when fewer than 10% of the window\n remains; a hint to slow down before hitting 429\n\nWhen a limit is exceeded the server returns `429 Too Many Requests` with\na `Retry-After` header (seconds) and an RFC 7807 body. Rejected requests\ndo not consume the limit or any credits — they are free to retry once\nthe reset period elapses.\n\n## Errors\n\nAll non-2xx responses follow [RFC 7807 `application/problem+json`](https://datatracker.ietf.org/doc/html/rfc7807).\nThe body always carries `type`, `title`, `status`, `detail`, `instance` and\n`request_id` (the same value as the `X-Request-Id` response header); 422\nresponses add `errors: [{param, message}]`, one entry per offending\nparameter.\n\nInput is validated strictly, so a mistake fails loudly instead of returning\nunfiltered data: an unknown query parameter, a value outside a parameter's\nvocabulary, and an inverted range (`revenue_min` above `revenue_max`,\n`founded_from` after `founded_to`) are all `422`. Enum values stay\ncase-insensitive and accept the documented alternative spellings.\n\n## Stability\n\nObject types carry an `object` discriminator (e.g. `\"object\": \"company\"`).\nNullable fields are always present (never omitted) and list fields default\nto `[]` so client codegen output is stable across releases.\n\n## Subscriptions\n\nStay current without polling on a fixed schedule: subscribe a single\ncompany to one data slice and firmendata detects changes for you on the\ncadence you choose, under `/subscriptions`. Pick one of three delivery\nmodes via `notification_type`:\n\n* **Webhook** (`notification_type: \"webhook\"`) — firmendata POSTs a\n signed JSON body to your `webhook_url` on each change. Every request is\n HMAC-SHA256 signed; verify `X-Firmendata-Signature` against the\n `webhook_secret` returned at creation. Requires `webhook_url`.\n\n* **Email** (`notification_type: \"email\"`) — a bilingual notification to\n `recipient_email` (with the register document attached as a PDF for\n `doc_*` subscriptions). Requires `recipient_email`.\n\n* **Poll-only / async** (`notification_type: null` — the **default**) —\n no outbound delivery is performed. The dispatcher records each detected\n change as a `subscription_event`, and you retrieve the rendered payload\n by polling `GET /subscriptions/{id}/events` with the subscription\n (job) id returned on create. Omit both `webhook_url` and\n `recipient_email`. Ideal when you can't expose an inbound webhook\n endpoint and would rather pull changes on your own schedule.\n\n`mode` selects `recurring` (deliver on every change) or `one_time`\n(poll until the first successful delivery, then stop); `cadence` controls\nhow often the change check runs.\n\n## Pricing\n\nEvery successful 2xx call deducts credits from your account; 4xx and 5xx\nresponses are free — no credits are spent on failed requests. A 2xx that\ncarries no data is free too: a cap table or UBO report whose\n`coverage.status` says there is nothing on file, or a financials response\nfor a company without any financial data. Every response states what it\ncost in the `X-Credits-Charged` header (`0` when nothing was charged).\n\nPer-endpoint credit costs, subscription pricing, plan allowances, and\nrate-limit tiers are listed on the [pricing page](https://firmendata.com/en/plans)\n— the single source of truth, so this spec links there instead of\nduplicating numbers that can drift.", + "description": "Programmatic access to German, Swiss and Austrian company data: register profile, filed\nfinancial statements, company events, and current cap tables.\n\n## Authentication\n\nBearer token in the `Authorization` header. Create and manage your\ntokens in [API Key Settings](https://firmendata.com/de/developer/api-keys).\n\n## Identifying your integration\n\nOptionally send `X-FirmenData-Client: /` (lower-case name of letters, digits, `.`, `_`, `-`, up to 32 characters; version up to 32 characters, e.g. `X-FirmenData-Client: acme-crm/2.1.0`) so we can tell integrations apart when you contact support. Our SDKs and the n8n node send it automatically. The header is informational only: it never changes pricing, rate limits or responses, and an invalid value is ignored.\n\n## Data freshness\n\nEvery company endpoint exposes a `freshness` object so you can see, at\na glance, how stale the response is and which fetch mode produced it:\n\n```jsonc\n\"freshness\": {\n \"object\": \"freshness\",\n \"last_checked_at\": \"2026-04-30T12:14:08Z\",\n \"realtime_fetching_status\": \"realtime_fetching_disabled\"\n}\n```\n\n`last_checked_at` is the UTC timestamp of the last successful refresh\nof the underlying data slice from the German registries. Compare it\nagainst your own staleness tolerance to decide which of the three\nfetch modes below fits the call. `null` means the slice has never been\nsuccessfully fetched for this company.\n\nThere are three ways to consume the data, each with a different\nfreshness guarantee:\n\n1. **Cached read (default).** Returns the most recent data we already\n have on file for the company. Cheapest and fastest. The data may\n be stale — the `freshness.last_checked_at` field tells you exactly\n how stale.\n\n2. **Realtime fetch (`fetch_realtime=true`).** Refreshes the\n underlying slice from the German registries inline before\n responding. Costs more credits and adds upstream latency\n (typically several seconds, sometimes longer). For company endpoints,\n this only affects German (DE) companies; Swiss (CH) and Austrian (AT)\n companies ignore the flag and incur no realtime surcharge. On upstream\n failure, the response falls back to the most recent cached data\n for the company; `freshness.realtime_fetching_status` reports the\n outcome (`success`, `platform_unavailable`,\n `documents_temporarily_unavailable`, `file_unavailable`,\n `company_unavailable`, or `realtime_fetching_disabled`). When we\n have no record of the company at all, the endpoint returns 404\n the same as without `fetch_realtime`.\n\n On `/companies/autocomplete` and `/companies/search` the flag means\n *discovery* rather than refresh: the keyword is searched against the\n German registers, so companies we do not have yet can appear in the\n same response with their identity and registered seat. Their full\n profile may become available shortly after at no extra cost.\n\n3. **Subscriptions (German companies only).** The only mode that *guarantees* you eventually\n see fresh data: failed data checks are retried on the\n configured cadence. Updates arrive by webhook or through the events API\n when something changes. Manage subscriptions via `/subscriptions`.\n\n## Pagination\n\nList endpoints (`/companies/search`, `/lists/{list_id}`, `/subscriptions`,\n`/subscriptions/{id}/events`) use opaque cursor pagination and return the\nsame `pagination` object. Pass the `pagination.next_cursor` value from a\nresponse back unchanged as the `cursor` query parameter to fetch the next\npage. Cursors are signed: one that was edited, or issued by a different\nendpoint, is rejected with `422`. A search cursor also remembers the\nfilters of the request that issued it, so the next page needs only\n`cursor`; repeating the same filters is fine, changing them is a `422`.\nSearch and list cursors are not\nguaranteed to survive a deploy. Feed cursors under `/v1/feeds` persist\nacross deploys and restarts: keep `next_cursor` even after an empty page.\nFeed positions expire after seven days; restart with `since` if expired.\n\n## Rate limits\n\nEvery bearer token is capped at:\n\n- **5 requests per second** (burst protection)\n- **100 requests per minute** (sustained)\n\nBoth windows are enforced via a sliding-window counter, so limits degrade\ngracefully across window boundaries. Every response — success or error —\nincludes headers so clients can self-pace:\n\n- `RateLimit-Limit` — numeric limit of the tighter window currently applying\n- `RateLimit-Remaining` — calls left in that window (never negative)\n- `RateLimit-Reset` — seconds until the window resets\n- `RateLimit-Warning` — present only when fewer than 10% of the window\n remains; a hint to slow down before hitting 429\n\nWhen a limit is exceeded the server returns `429 Too Many Requests` with\na `Retry-After` header (seconds) and an RFC 7807 body. Rejected requests\ndo not consume the limit or any credits — they are free to retry once\nthe reset period elapses.\n\n## Errors\n\nAll non-2xx responses follow [RFC 7807 `application/problem+json`](https://datatracker.ietf.org/doc/html/rfc7807).\nThe body always carries `type`, `title`, `status`, `detail`, `instance` and\n`request_id` (the same value as the `X-Request-Id` response header); 422\nresponses add `errors: [{param, message}]`, one entry per offending\nparameter.\n\nInput is validated strictly, so a mistake fails loudly instead of returning\nunfiltered data: an unknown query parameter, a value outside a parameter's\nvocabulary, and an inverted range (`revenue_min` above `revenue_max`,\n`founded_from` after `founded_to`) are all `422`. Enum values stay\ncase-insensitive and accept the documented alternative spellings.\n\n## Stability\n\nObject types carry an `object` discriminator (e.g. `\"object\": \"company\"`).\nNullable fields are always present (never omitted) and list fields default\nto `[]` so client codegen output is stable across releases.\n\n## Subscriptions\n\nStay current without polling on a fixed schedule: subscribe a single\ncompany to one data slice and firmendata detects changes for you on the\ncadence you choose, under `/subscriptions`. Pick one of three delivery\nmodes via `notification_type`:\n\n* **Webhook** (`notification_type: \"webhook\"`) — firmendata POSTs a\n signed JSON body to your `webhook_url` on each change. Every request is\n HMAC-SHA256 signed; verify `X-Firmendata-Signature` against the\n `webhook_secret` returned at creation. Requires `webhook_url`.\n\n* **Email** (`notification_type: \"email\"`) — a bilingual notification to\n `recipient_email` (with the register document attached as a PDF for\n `doc_*` subscriptions). Requires `recipient_email`.\n\n* **Poll-only / async** (`notification_type: null` — the **default**) —\n no outbound delivery is performed. Each detected\n change becomes a `subscription_event`, and you retrieve the payload\n by polling `GET /subscriptions/{id}/events` with the subscription\n (job) id returned on create. Omit both `webhook_url` and\n `recipient_email`. Ideal when you can't expose an inbound webhook\n endpoint and would rather pull changes on your own schedule.\n\n`mode` selects `recurring` (deliver on every change) or `one_time`\n(poll until the first successful delivery, then stop); `cadence` controls\nhow often the change check runs.\n\n## Pricing\n\nEvery successful 2xx call deducts credits from your account; 4xx and 5xx\nresponses are free — no credits are spent on failed requests. A 2xx that\ncarries no data is free too: a cap table or UBO report whose\n`coverage.status` says there is nothing on file, or a financials response\nfor a company without any financial data. Every response states what it\ncost in the `X-Credits-Charged` header (`0` when nothing was charged).\n\nPer-endpoint credit costs, subscription pricing, plan allowances, and\nrate-limit tiers are listed on the [pricing page](https://firmendata.com/en/plans)\n— the single source of truth, so this spec links there instead of\nduplicating numbers that can drift.", "license": { "name": "Commercial", "url": "https://firmendata.com/en/terms-and-conditions" }, "title": "firmendata Public API", - "version": "1.2.0" + "version": "1.3.0" }, "openapi": "3.1.0", "paths": { "/v1/companies/autocomplete": { "get": { - "description": "Companies whose name starts with `q`, from a single character, so a search box can suggest from the first keystroke. Exact core-name matches (ignoring legal-form suffixes) come first, then biggest revenue. From three characters, companies whose name contains `q` as a whole word also rank by revenue. Use `country` to restrict suggestions to a company register. Each hit carries just enough context — company name and register reference — to pick the right result out of a dropdown. Not meant for programmatic search — use `/companies/search` for that.\n\n**Free without an API key.** This is the only v1 endpoint you can call unauthenticated. Keyless access is rate limited — modestly, and by address — and is intended for trying the API out, for a search box, or for low-volume use. Send an `Authorization: Bearer ` header for substantially higher limits; the call still costs 0 credits either way. Respect `Retry-After` on a `429`.\n\nSet `fetch_realtime=true` (with `q` of at least 3 characters) to also search the German company registers live and index any companies not yet known to firmendata — this is how a company founded days ago can be found before the regular register sweep reaches it. The freshly indexed companies appear in the same response. **`fetch_realtime` requires an API key** and is billed; keyless requests that set it are rejected with `401`.\n\n\n## Pricing\n\nSee the [pricing page](https://firmendata.com/en/plans) for this endpoint's credit cost.", + "description": "Companies whose name starts with `q`, from a single character, so a search box can suggest from the first keystroke. Exact core-name matches (ignoring legal-form suffixes) come first, then biggest revenue. From three characters, companies whose name contains `q` as a whole word also rank by revenue. Use `country` to restrict suggestions to a company register. Each hit carries just enough context — company name and register reference — to pick the right result out of a dropdown. Not meant for programmatic search — use `/companies/search` for that.\n\n**Free without an API key.** This is the only v1 endpoint you can call unauthenticated. Keyless access is rate limited — modestly, and by address — and is intended for trying the API out, for a search box, or for low-volume use. Send an `Authorization: Bearer ` header for substantially higher limits; the call still costs 0 credits either way. Respect `Retry-After` on a `429`.\n\nSet `fetch_realtime=true` (with `q` of at least 3 characters) to also search the German company registers live, so companies we do not have yet — including ones registered only days ago — appear in the same response. **`fetch_realtime` requires an API key** and is billed; keyless requests that set it are rejected with `401`.\n\n\n## Pricing\n\nSee the [pricing page](https://firmendata.com/en/plans) for this endpoint's credit cost.", "operationId": "autocompleteCompanies", "parameters": [ { @@ -8575,13 +8580,13 @@ } }, { - "description": "When `true`, search the German company registers live for the keyword first and index any hits not yet known to firmendata, so newly founded companies appear in the suggestions of this same response. Adds upstream latency (typically a few seconds) and a higher credit cost. Scraping failures never fail the request — `freshness.realtime_fetching_status` reports the outcome and the local suggestions are returned regardless.", + "description": "When `true`, search the German company registers live for the keyword and include any companies not yet known to firmendata, so newly founded companies appear in the suggestions of this same response. Adds upstream latency (typically a few seconds) and a higher credit cost. Refresh failures never fail the request — `freshness.realtime_fetching_status` reports the outcome and the local suggestions are returned regardless.", "in": "query", "name": "fetch_realtime", "required": false, "schema": { "default": false, - "description": "When `true`, search the German company registers live for the keyword first and index any hits not yet known to firmendata, so newly founded companies appear in the suggestions of this same response. Adds upstream latency (typically a few seconds) and a higher credit cost. Scraping failures never fail the request — `freshness.realtime_fetching_status` reports the outcome and the local suggestions are returned regardless.", + "description": "When `true`, search the German company registers live for the keyword and include any companies not yet known to firmendata, so newly founded companies appear in the suggestions of this same response. Adds upstream latency (typically a few seconds) and a higher credit cost. Refresh failures never fail the request — `freshness.realtime_fetching_status` reports the outcome and the local suggestions are returned regardless.", "title": "Fetch Realtime", "type": "boolean" } @@ -8613,6 +8618,7 @@ "example": { "detail": "A valid `Authorization: Bearer ` header is required.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "A valid `Authorization: Bearer ` header is required.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 401, "title": "Authentication required.", @@ -8625,6 +8631,29 @@ }, "description": "Missing, invalid, revoked, or expired bearer token." }, + "402": { + "content": { + "application/problem+json": { + "examples": { + "insufficient_credits": { + "value": { + "detail": "Not enough credits for this call; top up or upgrade your plan", + "instance": "/v1/companies/search", + "message": "Not enough credits for this call; top up or upgrade your plan", + "request_id": "req_01HXYZABC123DEF456GHI789J", + "status": 402, + "title": "Insufficient credits.", + "type": "https://api.firmendata.com/problems/insufficient-credits" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not enough credits for this call; top up or upgrade your plan" + }, "422": { "content": { "application/problem+json": { @@ -8637,6 +8666,7 @@ } ], "instance": "/v1/companies/search", + "message": "limit: Input should be less than or equal to 50", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 422, "title": "Request validation failed.", @@ -8655,6 +8685,7 @@ "example": { "detail": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "instance": "/v1/companies/autocomplete", + "message": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 429, "title": "Too many requests.", @@ -8702,6 +8733,7 @@ "example": { "detail": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 500, "title": "Internal server error.", @@ -8741,7 +8773,7 @@ }, "/v1/companies/search": { "get": { - "description": "Full-text + filter search over German, Swiss and Austrian commercial registers. Cursor-paginated,\nup to 50 hits per page. `total_approx` caps at 10,000 — tighten filters for an\nexact count.\n\nEach hit carries identity and registered seat, the register reference (court,\ntype, number), headline financials in EUR, the company's website, and the CPV\ncodes of any EU tenders it won.\n\nFilter values are case-insensitive, several filters accept multiple values, and\nthe financial figures need one caveat before you compute ratios with them — see\nthe **Search** section above for all three. Unknown parameters, unknown filter\nvalues and inverted ranges (`*_min` above `*_max`, `founded_from` after\n`founded_to`) are rejected with `422` instead of being ignored.\n\nSetting `fetch_realtime=true` additionally searches the German registers live\nfor `q` (or `company_name`, one of which is then required) and appends any\ncompanies not yet indexed by firmendata to the first page, carrying identity and\nseat only. First page only — omit `cursor`.\n\n\n## Pricing\n\nSee the [pricing page](https://firmendata.com/en/plans) for this endpoint's credit cost.", + "description": "Full-text + filter search over German, Swiss and Austrian commercial registers. Cursor-paginated,\nup to 50 hits per page. `total_approx` caps at 10,000 — tighten filters for an\nexact count.\n\nEach hit carries identity and registered seat, the register reference (court,\ntype, number), headline financials in EUR, the company's website, and the CPV\ncodes of any EU tenders it won.\n\nFilter values are case-insensitive, several filters accept multiple values, and\nthe financial figures need one caveat before you compute ratios with them — see\nthe **Search** section above for all three. Unknown parameters, unknown filter\nvalues and inverted ranges (`*_min` above `*_max`, `founded_from` after\n`founded_to`) are rejected with `422` instead of being ignored.\n\nSetting `fetch_realtime=true` additionally searches the German registers live\nfor `q` (or `company_name`, one of which is then required) and appends any\ncompanies not yet known to firmendata to the first page, carrying identity and\nseat only. First page only — omit `cursor`.\n\n\n## Pricing\n\nSee the [pricing page](https://firmendata.com/en/plans) for this endpoint's credit cost.", "operationId": "searchCompanies", "parameters": [ { @@ -8770,7 +8802,7 @@ } }, { - "description": "Company name prefix, matched against the folded legal name with quotes and invisible characters stripped — `Müller \"Süd\" GmbH` and `Müller Süd GmbH` hit the same row. The legal-form suffix is kept, so `Bosch GmbH` narrows against `Bosch`.", + "description": "Company name prefix, ignoring case, accents, quotes and invisible characters — `Müller \"Süd\" GmbH` and `Müller Süd GmbH` match the same company. The legal-form suffix is kept, so `Bosch GmbH` narrows against `Bosch`.", "in": "query", "name": "company_name", "required": false, @@ -8785,7 +8817,7 @@ "type": "null" } ], - "description": "Company name prefix, matched against the folded legal name with quotes and invisible characters stripped — `Müller \"Süd\" GmbH` and `Müller Süd GmbH` hit the same row. The legal-form suffix is kept, so `Bosch GmbH` narrows against `Bosch`.", + "description": "Company name prefix, ignoring case, accents, quotes and invisible characters — `Müller \"Süd\" GmbH` and `Müller Süd GmbH` match the same company. The legal-form suffix is kept, so `Bosch GmbH` narrows against `Bosch`.", "examples": [ "bayerische motoren werke" ], @@ -9384,7 +9416,7 @@ } }, { - "description": "Company lifecycle status, resolved from the merged Handelsregister (SI + CD) and Insolvenzbekanntmachungen timelines, plus SHAB KK/NA notices for Swiss companies. Multi-valued.\n\n- `active` — no insolvency or dissolution on record\n- `insolvent` — proceedings open or preliminary measures in force\n- `in_liquidation` — dissolved, with a Liquidator in office\n- `dissolved` — dissolved with no Liquidator, or a proceeding since lifted\n- `deleted` — removed from the register; outranks the others\n\nThe five are disjoint and exhaustive, so selecting every value is the same as selecting none.", + "description": "Company lifecycle status according to the commercial register and insolvency notices, including Swiss Official Gazette of Commerce (SHAB) bankruptcy and composition notices. Multi-valued.\n\n- `active` — no insolvency or dissolution on record\n- `insolvent` — proceedings open or preliminary measures in force\n- `in_liquidation` — dissolved, with a Liquidator in office\n- `dissolved` — dissolved with no Liquidator, or a proceeding since lifted\n- `deleted` — removed from the register; outranks the others\n\nThe five are disjoint and exhaustive, so selecting every value is the same as selecting none.", "in": "query", "name": "legal_status", "required": false, @@ -9400,7 +9432,7 @@ "type": "null" } ], - "description": "Company lifecycle status, resolved from the merged Handelsregister (SI + CD) and Insolvenzbekanntmachungen timelines, plus SHAB KK/NA notices for Swiss companies. Multi-valued.\n\n- `active` — no insolvency or dissolution on record\n- `insolvent` — proceedings open or preliminary measures in force\n- `in_liquidation` — dissolved, with a Liquidator in office\n- `dissolved` — dissolved with no Liquidator, or a proceeding since lifted\n- `deleted` — removed from the register; outranks the others\n\nThe five are disjoint and exhaustive, so selecting every value is the same as selecting none.", + "description": "Company lifecycle status according to the commercial register and insolvency notices, including Swiss Official Gazette of Commerce (SHAB) bankruptcy and composition notices. Multi-valued.\n\n- `active` — no insolvency or dissolution on record\n- `insolvent` — proceedings open or preliminary measures in force\n- `in_liquidation` — dissolved, with a Liquidator in office\n- `dissolved` — dissolved with no Liquidator, or a proceeding since lifted\n- `deleted` — removed from the register; outranks the others\n\nThe five are disjoint and exhaustive, so selecting every value is the same as selecting none.", "examples": [ [ "insolvent", @@ -9548,13 +9580,13 @@ } }, { - "description": "Also search the German registers live for `q` / `company_name` (one is required with this flag) and append any companies not yet indexed, carrying identity and seat only. First page only. Adds a few seconds of latency and costs more credits; scraping failures never fail the request — check `freshness.realtime_fetching_status`.", + "description": "Also search the German registers live for `q` / `company_name` (one is required with this flag) and append any companies not yet known to us, carrying identity and seat only. First page only. Adds a few seconds of latency and costs more credits; refresh failures never fail the request — check `freshness.realtime_fetching_status`.", "in": "query", "name": "fetch_realtime", "required": false, "schema": { "default": false, - "description": "Also search the German registers live for `q` / `company_name` (one is required with this flag) and append any companies not yet indexed, carrying identity and seat only. First page only. Adds a few seconds of latency and costs more credits; scraping failures never fail the request — check `freshness.realtime_fetching_status`.", + "description": "Also search the German registers live for `q` / `company_name` (one is required with this flag) and append any companies not yet known to us, carrying identity and seat only. First page only. Adds a few seconds of latency and costs more credits; refresh failures never fail the request — check `freshness.realtime_fetching_status`.", "title": "Fetch Realtime", "type": "boolean" } @@ -9607,7 +9639,7 @@ } }, { - "description": "Restrict to companies where a person holding an ownership or governing role at first registration is still in office (`true`), or where the founding officers have all been replaced (`false`).\n\nRequires the chronological register extract, so companies whose history has not been parsed are excluded from both values. Companies whose extract begins with a transfer from another register court are also excluded: their first entry records the move, not the founding.", + "description": "Restrict to companies where a person holding an ownership or governing role at first registration is still in office (`true`), or where the founding officers have all been replaced (`false`).\n\nRequires the chronological register extract, so companies whose register history is unavailable are excluded from both values. Companies whose extract begins with a transfer from another register court are also excluded: their first entry records the move, not the founding.", "in": "query", "name": "founder_led", "required": false, @@ -9620,7 +9652,7 @@ "type": "null" } ], - "description": "Restrict to companies where a person holding an ownership or governing role at first registration is still in office (`true`), or where the founding officers have all been replaced (`false`).\n\nRequires the chronological register extract, so companies whose history has not been parsed are excluded from both values. Companies whose extract begins with a transfer from another register court are also excluded: their first entry records the move, not the founding.", + "description": "Restrict to companies where a person holding an ownership or governing role at first registration is still in office (`true`), or where the founding officers have all been replaced (`false`).\n\nRequires the chronological register extract, so companies whose register history is unavailable are excluded from both values. Companies whose extract begins with a transfer from another register court are also excluded: their first entry records the move, not the founding.", "examples": [ true ], @@ -9675,6 +9707,7 @@ "example": { "detail": "A valid `Authorization: Bearer ` header is required.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "A valid `Authorization: Bearer ` header is required.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 401, "title": "Authentication required.", @@ -9687,6 +9720,29 @@ }, "description": "Missing, invalid, revoked, or expired bearer token." }, + "402": { + "content": { + "application/problem+json": { + "examples": { + "insufficient_credits": { + "value": { + "detail": "Not enough credits for this call; top up or upgrade your plan", + "instance": "/v1/companies/search", + "message": "Not enough credits for this call; top up or upgrade your plan", + "request_id": "req_01HXYZABC123DEF456GHI789J", + "status": 402, + "title": "Insufficient credits.", + "type": "https://api.firmendata.com/problems/insufficient-credits" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not enough credits for this call; top up or upgrade your plan" + }, "422": { "content": { "application/problem+json": { @@ -9699,6 +9755,7 @@ } ], "instance": "/v1/companies/search", + "message": "limit: Input should be less than or equal to 50", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 422, "title": "Request validation failed.", @@ -9717,6 +9774,7 @@ "example": { "detail": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "instance": "/v1/companies/autocomplete", + "message": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 429, "title": "Too many requests.", @@ -9764,6 +9822,7 @@ "example": { "detail": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 500, "title": "Internal server error.", @@ -9822,13 +9881,13 @@ } }, { - "description": "Only affects German (DE) companies; ignored for Swiss (CH) and Austrian (AT) companies, with no realtime surcharge. When `true`, refresh the company's register snapshot from the German registries before building the response, so the underlying data is as fresh as possible. Adds upstream latency (typically several seconds). Scraping failures are logged but do not fail the request — the most recent indexed data is returned instead. The chronological register history is refreshed by `/v1/companies/{eu_id}/history` instead — this endpoint does not refresh it.", + "description": "Only affects German (DE) companies; ignored for Swiss (CH) and Austrian (AT) companies, with no realtime surcharge. When `true`, refresh the company's register snapshot from the German registries before building the response, so the underlying data is as fresh as possible. Adds upstream latency (typically several seconds). Refresh failures do not fail the request — the most recent available data is returned instead. The chronological register history is refreshed by `/v1/companies/{eu_id}/history` instead — this endpoint does not refresh it.", "in": "query", "name": "fetch_realtime", "required": false, "schema": { "default": false, - "description": "Only affects German (DE) companies; ignored for Swiss (CH) and Austrian (AT) companies, with no realtime surcharge. When `true`, refresh the company's register snapshot from the German registries before building the response, so the underlying data is as fresh as possible. Adds upstream latency (typically several seconds). Scraping failures are logged but do not fail the request — the most recent indexed data is returned instead. The chronological register history is refreshed by `/v1/companies/{eu_id}/history` instead — this endpoint does not refresh it.", + "description": "Only affects German (DE) companies; ignored for Swiss (CH) and Austrian (AT) companies, with no realtime surcharge. When `true`, refresh the company's register snapshot from the German registries before building the response, so the underlying data is as fresh as possible. Adds upstream latency (typically several seconds). Refresh failures do not fail the request — the most recent available data is returned instead. The chronological register history is refreshed by `/v1/companies/{eu_id}/history` instead — this endpoint does not refresh it.", "title": "Fetch Realtime", "type": "boolean" } @@ -9860,6 +9919,7 @@ "example": { "detail": "A valid `Authorization: Bearer ` header is required.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "A valid `Authorization: Bearer ` header is required.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 401, "title": "Authentication required.", @@ -9872,12 +9932,36 @@ }, "description": "Missing, invalid, revoked, or expired bearer token." }, + "402": { + "content": { + "application/problem+json": { + "examples": { + "insufficient_credits": { + "value": { + "detail": "Not enough credits for this call; top up or upgrade your plan", + "instance": "/v1/companies/search", + "message": "Not enough credits for this call; top up or upgrade your plan", + "request_id": "req_01HXYZABC123DEF456GHI789J", + "status": 402, + "title": "Insufficient credits.", + "type": "https://api.firmendata.com/problems/insufficient-credits" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not enough credits for this call; top up or upgrade your plan" + }, "404": { "content": { "application/problem+json": { "example": { "detail": "No company with id DEF1103R.HRB999999B.", "instance": "/v1/companies/DEF1103R.HRB999999B", + "message": "No company with id DEF1103R.HRB999999B.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 404, "title": "Resource not found.", @@ -9902,6 +9986,7 @@ } ], "instance": "/v1/companies/search", + "message": "limit: Input should be less than or equal to 50", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 422, "title": "Request validation failed.", @@ -9920,6 +10005,7 @@ "example": { "detail": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "instance": "/v1/companies/autocomplete", + "message": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 429, "title": "Too many requests.", @@ -9967,6 +10053,7 @@ "example": { "detail": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 500, "title": "Internal server error.", @@ -10005,7 +10092,7 @@ }, "/v1/companies/{eu_id}/documents": { "get": { - "description": "Check the German registries live and list register documents,\nincluding older DK versions. This typically takes several seconds.\n\nPass a DK item's `document_id` and its `type` as `file_type` to\n`GET /v1/companies/{eu_id}/documents/download` to download that version.\n`register_extract_current` and `register_extract_chronological` are generated\non request and have no `document_id`; download them by `file_type`.\n`stored` indicates that a copy is already available for immediate download.\n\nWhen the registries are unreachable or the company is no longer listed,\nthe response serves the stored catalog with the outcome in `freshness`.\nUnreachable-registry, unavailable-company and empty answers are not billed.\nSwiss and Austrian companies return an empty list with `coverage.status=not_applicable`.\nThis endpoint always checks live and accepts no `fetch_realtime` parameter.\n\n\n## Pricing\n\nSee the [pricing page](https://firmendata.com/en/plans) for this endpoint's credit cost.", + "description": "Check the German registries live and list register documents,\nincluding older document versions. This typically takes several seconds.\n\nPass a document's `document_id` and its `type` as `file_type` to\n`GET /v1/companies/{eu_id}/documents/download` to download that version.\n`register_extract_current` and `register_extract_chronological` are generated\non request and have no `document_id`; download them by `file_type`.\n`stored` indicates that a copy is already available for immediate download.\n\nWhen the registries are unreachable or the company is no longer listed,\nthe response serves the stored catalog with the outcome in `freshness`.\nUnreachable-registry, unavailable-company and empty answers are not billed.\nSwiss and Austrian companies return an empty list with `coverage.status=not_applicable`.\nThis endpoint always checks live and accepts no `fetch_realtime` parameter.\n\n\n## Pricing\n\nSee the [pricing page](https://firmendata.com/en/plans) for this endpoint's credit cost.", "operationId": "listCompanyDocuments", "parameters": [ { @@ -10048,6 +10135,7 @@ "example": { "detail": "A valid `Authorization: Bearer ` header is required.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "A valid `Authorization: Bearer ` header is required.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 401, "title": "Authentication required.", @@ -10060,12 +10148,36 @@ }, "description": "Missing, invalid, revoked, or expired bearer token." }, + "402": { + "content": { + "application/problem+json": { + "examples": { + "insufficient_credits": { + "value": { + "detail": "Not enough credits for this call; top up or upgrade your plan", + "instance": "/v1/companies/search", + "message": "Not enough credits for this call; top up or upgrade your plan", + "request_id": "req_01HXYZABC123DEF456GHI789J", + "status": 402, + "title": "Insufficient credits.", + "type": "https://api.firmendata.com/problems/insufficient-credits" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not enough credits for this call; top up or upgrade your plan" + }, "404": { "content": { "application/problem+json": { "example": { "detail": "No company with id DEF1103R.HRB999999B.", "instance": "/v1/companies/DEF1103R.HRB999999B", + "message": "No company with id DEF1103R.HRB999999B.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 404, "title": "Resource not found.", @@ -10090,6 +10202,7 @@ } ], "instance": "/v1/companies/search", + "message": "limit: Input should be less than or equal to 50", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 422, "title": "Request validation failed.", @@ -10108,6 +10221,7 @@ "example": { "detail": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "instance": "/v1/companies/autocomplete", + "message": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 429, "title": "Too many requests.", @@ -10155,6 +10269,7 @@ "example": { "detail": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 500, "title": "Internal server error.", @@ -10193,7 +10308,7 @@ }, "/v1/companies/{eu_id}/documents/download": { "get": { - "description": "Resolve one register document for the company and return a\nshort-lived presigned download URL alongside its metadata.\n\n- **`file_type`** — required. One of:\n\n | `file_type` | German term | Coverage |\n |---|---|---|\n | `register_extract_current` | Aktueller Abdruck | Every Handelsregister-registered company. |\n | `register_extract_chronological` | Chronologischer Abdruck | Every Handelsregister-registered company. |\n | `shareholder_list` | Liste der Gesellschafter | GmbH and UG (haftungsbeschränkt) only. Companies founded via the simplified procedure (§2 Abs. 1a GmbHG) file no separate Liste — request `model_protocol` instead to download their founding deed. **For parsed cap-table data** (which already auto-falls-back to the Musterprotokoll), call `/companies/{eu_id}/shareholders` rather than this endpoint. |\n | `articles_of_association` | Gesellschaftsvertrag / Satzung / Statut (the German name varies by legal form: Gesellschaftsvertrag for GmbH/KG/OHG/GbR, Satzung for AG/eG/Verein, Statut for some specific forms) | Most companies — but not those founded via the simplified procedure, which use a Musterprotokoll (`model_protocol`) in place of a constitutional document. |\n | `company_registration` | Anmeldung | Any newly-registered company. |\n | `model_protocol` | Musterprotokoll | **Only** companies founded with the simplified procedure under §2 Abs. 1a GmbHG. Legally substitutes for both the constitutional document (Gesellschaftsvertrag / Satzung / Statut) and the Liste der Gesellschafter for those companies — it names the founders and their share allocations directly. |\n\n Wire values mirror the corresponding `subscription_type` values on\n `POST /v1/subscriptions`, so a webhook event_type like\n `articles_of_association.updated` can be passed straight through here\n to download the file that triggered it.\n- **`file_id`** — optional. When omitted, falls back to the latest published\n file of the requested type.\n- **`document_id`** — optional catalog identifier (`doc_`) from\n `GET /v1/companies/{eu_id}/documents`. Downloads that specific DK version,\n including older versions, fetching it on demand when no stored copy exists.\n `file_type` must match. Cannot be combined with `file_id` or\n `fetch_realtime=true`. Register extracts have no catalog identifier;\n request them by `file_type`.\n- **`fetch_realtime`** — optional. When `true`, fetch the requested\n `file_type` from the German registries before resolving the file, so the\n presigned URL points at the freshest version. Adds upstream latency\n (typically several seconds). Failures are surfaced via\n `freshness.realtime_fetching_status`; the response still falls back to the most\n recent indexed file when realtime fetching fails.\n\nReturns `404` when no matching document is available — the `detail`\nfield distinguishes whether the document is unknown to the German\nregistries, whether the company is no longer present in the registries,\nor whether the registries were unreachable. A `404` costs no credits. The\n`download_url` is valid for 1 day; refetch this endpoint to obtain a new\nURL when it expires.\n\n`is_outdated` flags a register extract older than the company's latest\nregister entry; request it with `fetch_realtime=true` for a current copy.\n\n\n## Pricing\n\nSee the [pricing page](https://firmendata.com/en/plans) for this endpoint's credit cost.", + "description": "Resolve one register document for the company and return a\nshort-lived presigned download URL alongside its metadata.\n\n- **`file_type`** — required. One of:\n\n | `file_type` | German term | Coverage |\n |---|---|---|\n | `register_extract_current` | Aktueller Abdruck | Every Handelsregister-registered company. |\n | `register_extract_chronological` | Chronologischer Abdruck | Every Handelsregister-registered company. |\n | `shareholder_list` | Liste der Gesellschafter | GmbH and UG (haftungsbeschränkt) only. Companies founded via the simplified procedure (§2 Abs. 1a GmbHG) file no separate Liste — request `model_protocol` instead to download their founding deed. **For parsed cap-table data** (which already auto-falls-back to the Musterprotokoll), call `/companies/{eu_id}/shareholders` rather than this endpoint. |\n | `articles_of_association` | Gesellschaftsvertrag / Satzung / Statut (the German name varies by legal form: Gesellschaftsvertrag for GmbH/KG/OHG/GbR, Satzung for AG/eG/Verein, Statut for some specific forms) | Most companies — but not those founded via the simplified procedure, which use a Musterprotokoll (`model_protocol`) in place of a constitutional document. |\n | `company_registration` | Anmeldung | Any newly-registered company. |\n | `model_protocol` | Musterprotokoll | **Only** companies founded with the simplified procedure under §2 Abs. 1a GmbHG. Legally substitutes for both the constitutional document (Gesellschaftsvertrag / Satzung / Statut) and the Liste der Gesellschafter for those companies — it names the founders and their share allocations directly. |\n\n Wire values mirror the corresponding `subscription_type` values on\n `POST /v1/subscriptions`, so a webhook event_type like\n `articles_of_association.updated` can be passed straight through here\n to download the file that triggered it.\n- **`file_id`** — optional. When omitted, falls back to the latest published\n file of the requested type.\n- **`document_id`** — optional catalog identifier (`doc_`) from\n `GET /v1/companies/{eu_id}/documents`. Downloads that specific document version,\n including older versions, fetching it on demand when no stored copy exists.\n `file_type` must match. Cannot be combined with `file_id` or\n `fetch_realtime=true`. Register extracts have no catalog identifier;\n request them by `file_type`.\n- **`fetch_realtime`** — optional. When `true`, fetch the requested\n `file_type` from the German registries before resolving the file, so the\n presigned URL points at the freshest version. Adds upstream latency\n (typically several seconds). Failures are surfaced via\n `freshness.realtime_fetching_status`; the response still falls back to the most\n recent available file when realtime fetching fails.\n\nReturns `404` when no matching document is available — the `detail`\nfield distinguishes whether the document is unknown to the German\nregistries, whether the company is no longer present in the registries,\nor whether the registries were unreachable. A `404` costs no credits. The\n`download_url` is valid for 1 day; refetch this endpoint to obtain a new\nURL when it expires.\n\n`is_outdated` flags a register extract older than the company's latest\nregister entry; request it with `fetch_realtime=true` for a current copy.\n\n\n## Pricing\n\nSee the [pricing page](https://firmendata.com/en/plans) for this endpoint's credit cost.", "operationId": "downloadCompanyDocument", "parameters": [ { @@ -10253,7 +10368,7 @@ } }, { - "description": "Specific DK catalog version from the live document list. Cannot be combined with `file_id` or `fetch_realtime=true`.", + "description": "Specific document version from the live document list. Cannot be combined with `file_id` or `fetch_realtime=true`.", "in": "query", "name": "document_id", "required": false, @@ -10267,7 +10382,7 @@ "type": "null" } ], - "description": "Specific DK catalog version from the live document list. Cannot be combined with `file_id` or `fetch_realtime=true`.", + "description": "Specific document version from the live document list. Cannot be combined with `file_id` or `fetch_realtime=true`.", "title": "Document Id" } }, @@ -10310,6 +10425,7 @@ "example": { "detail": "A valid `Authorization: Bearer ` header is required.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "A valid `Authorization: Bearer ` header is required.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 401, "title": "Authentication required.", @@ -10322,12 +10438,36 @@ }, "description": "Missing, invalid, revoked, or expired bearer token." }, + "402": { + "content": { + "application/problem+json": { + "examples": { + "insufficient_credits": { + "value": { + "detail": "Not enough credits for this call; top up or upgrade your plan", + "instance": "/v1/companies/search", + "message": "Not enough credits for this call; top up or upgrade your plan", + "request_id": "req_01HXYZABC123DEF456GHI789J", + "status": 402, + "title": "Insufficient credits.", + "type": "https://api.firmendata.com/problems/insufficient-credits" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not enough credits for this call; top up or upgrade your plan" + }, "404": { "content": { "application/problem+json": { "example": { "detail": "No company with id DEF1103R.HRB999999B.", "instance": "/v1/companies/DEF1103R.HRB999999B", + "message": "No company with id DEF1103R.HRB999999B.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 404, "title": "Resource not found.", @@ -10352,6 +10492,7 @@ } ], "instance": "/v1/companies/search", + "message": "limit: Input should be less than or equal to 50", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 422, "title": "Request validation failed.", @@ -10370,6 +10511,7 @@ "example": { "detail": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "instance": "/v1/companies/autocomplete", + "message": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 429, "title": "Too many requests.", @@ -10417,6 +10559,7 @@ "example": { "detail": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 500, "title": "Internal server error.", @@ -10562,6 +10705,7 @@ "example": { "detail": "A valid `Authorization: Bearer ` header is required.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "A valid `Authorization: Bearer ` header is required.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 401, "title": "Authentication required.", @@ -10574,12 +10718,36 @@ }, "description": "Missing, invalid, revoked, or expired bearer token." }, + "402": { + "content": { + "application/problem+json": { + "examples": { + "insufficient_credits": { + "value": { + "detail": "Not enough credits for this call; top up or upgrade your plan", + "instance": "/v1/companies/search", + "message": "Not enough credits for this call; top up or upgrade your plan", + "request_id": "req_01HXYZABC123DEF456GHI789J", + "status": 402, + "title": "Insufficient credits.", + "type": "https://api.firmendata.com/problems/insufficient-credits" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not enough credits for this call; top up or upgrade your plan" + }, "404": { "content": { "application/problem+json": { "example": { "detail": "No company with id DEF1103R.HRB999999B.", "instance": "/v1/companies/DEF1103R.HRB999999B", + "message": "No company with id DEF1103R.HRB999999B.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 404, "title": "Resource not found.", @@ -10604,6 +10772,7 @@ } ], "instance": "/v1/companies/search", + "message": "limit: Input should be less than or equal to 50", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 422, "title": "Request validation failed.", @@ -10622,6 +10791,7 @@ "example": { "detail": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "instance": "/v1/companies/autocomplete", + "message": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 429, "title": "Too many requests.", @@ -10669,6 +10839,7 @@ "example": { "detail": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 500, "title": "Internal server error.", @@ -10707,7 +10878,7 @@ }, "/v1/companies/{eu_id}/history": { "get": { - "description": "Aggregated chronological register-entry history for a company.\n\nFor each kind of change — company name (`names`), seat (`seats`), share\ncapital (`share_capital`), officers (`board_changes`), business purpose,\nlegal form and articles — the endpoint returns the full timeline of\nentries that affected it, oldest first. `current_board` is who holds office\nnow, derived by replaying every appointment, departure and correction.\nDates are ISO 8601; people use the same `person` object as the company\ndetail.\n\n- **`fetch_realtime`** — optional. When `true`, refresh the\n underlying register history from the German registries before\n responding. Adds upstream latency (typically several seconds).\n Failures are surfaced via `freshness.realtime_fetching_status`; the response\n still falls back to the most recent indexed data when a refresh\n fails.\n\n`coverage.status` is `not_available` when no chronological extract exists\nfor the company — the Swiss commercial register publishes none, Austrian\nhistory is not available yet, and some German entries have not been\nretrieved yet. Such responses are not charged;\nSwiss companies still receive their legal-status timeline from SHAB.\nA 404 means the company itself is unknown.\nThe same data backs the `history` webhook subscription type, so the\nshape returned here is exactly the shape downstream consumers fetch\nwhen responding to a `history.updated` webhook.\n\n\n## Pricing\n\nSee the [pricing page](https://firmendata.com/en/plans) for this endpoint's credit cost.", + "description": "Aggregated chronological register-entry history for a company.\n\nFor each kind of change — company name (`names`), seat (`seats`), share\ncapital (`share_capital`), officers (`board_changes`), business purpose,\nlegal form and articles — the endpoint returns the full timeline of\nentries that affected it, oldest first. `current_board` is who holds office\nnow, taking appointments, departures and corrections into account.\nDates are ISO 8601; people use the same `person` object as the company\ndetail.\n\n- **`fetch_realtime`** — optional. When `true`, refresh the\n underlying register history from the German registries before\n responding. Adds upstream latency (typically several seconds).\n Failures are surfaced via `freshness.realtime_fetching_status`; the response\n still falls back to the most recent available data when a refresh\n fails.\n\n`coverage.status` is `not_available` when no chronological extract exists\nfor the company — the Swiss commercial register publishes none, Austrian\nhistory is not available yet, and some German entries have not been\nretrieved yet. Such responses are not charged;\nSwiss companies still receive their legal-status timeline from the Swiss Official Gazette of Commerce (SHAB).\nA 404 means the company itself is unknown.\nThe same data backs the `history` webhook subscription type, so the\nshape returned here is exactly the shape downstream consumers fetch\nwhen responding to a `history.updated` webhook.\n\n\n## Pricing\n\nSee the [pricing page](https://firmendata.com/en/plans) for this endpoint's credit cost.", "operationId": "getCompanyHistory", "parameters": [ { @@ -10727,13 +10898,13 @@ } }, { - "description": "Only affects German (DE) companies; ignored for Swiss (CH) and Austrian (AT) companies, with no realtime surcharge. When `true`, refresh the chronological register history from the German registries before responding. Adds upstream latency (typically several seconds). Scraping failures are surfaced via `freshness.realtime_fetching_status` rather than failing the request.", + "description": "Only affects German (DE) companies; ignored for Swiss (CH) and Austrian (AT) companies, with no realtime surcharge. When `true`, refresh the chronological register history from the German registries before responding. Adds upstream latency (typically several seconds). Refresh failures are surfaced via `freshness.realtime_fetching_status` rather than failing the request.", "in": "query", "name": "fetch_realtime", "required": false, "schema": { "default": false, - "description": "Only affects German (DE) companies; ignored for Swiss (CH) and Austrian (AT) companies, with no realtime surcharge. When `true`, refresh the chronological register history from the German registries before responding. Adds upstream latency (typically several seconds). Scraping failures are surfaced via `freshness.realtime_fetching_status` rather than failing the request.", + "description": "Only affects German (DE) companies; ignored for Swiss (CH) and Austrian (AT) companies, with no realtime surcharge. When `true`, refresh the chronological register history from the German registries before responding. Adds upstream latency (typically several seconds). Refresh failures are surfaced via `freshness.realtime_fetching_status` rather than failing the request.", "title": "Fetch Realtime", "type": "boolean" } @@ -10765,6 +10936,7 @@ "example": { "detail": "A valid `Authorization: Bearer ` header is required.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "A valid `Authorization: Bearer ` header is required.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 401, "title": "Authentication required.", @@ -10777,12 +10949,36 @@ }, "description": "Missing, invalid, revoked, or expired bearer token." }, + "402": { + "content": { + "application/problem+json": { + "examples": { + "insufficient_credits": { + "value": { + "detail": "Not enough credits for this call; top up or upgrade your plan", + "instance": "/v1/companies/search", + "message": "Not enough credits for this call; top up or upgrade your plan", + "request_id": "req_01HXYZABC123DEF456GHI789J", + "status": 402, + "title": "Insufficient credits.", + "type": "https://api.firmendata.com/problems/insufficient-credits" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not enough credits for this call; top up or upgrade your plan" + }, "404": { "content": { "application/problem+json": { "example": { "detail": "No company with id DEF1103R.HRB999999B.", "instance": "/v1/companies/DEF1103R.HRB999999B", + "message": "No company with id DEF1103R.HRB999999B.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 404, "title": "Resource not found.", @@ -10807,6 +11003,7 @@ } ], "instance": "/v1/companies/search", + "message": "limit: Input should be less than or equal to 50", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 422, "title": "Request validation failed.", @@ -10825,6 +11022,7 @@ "example": { "detail": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "instance": "/v1/companies/autocomplete", + "message": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 429, "title": "Too many requests.", @@ -10872,6 +11070,7 @@ "example": { "detail": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 500, "title": "Internal server error.", @@ -10910,7 +11109,7 @@ }, "/v1/companies/{eu_id}/shareholders": { "get": { - "description": "Latest aggregated Gesellschafterliste (cap table) for a GmbH or UG.\n\n- **Coverage status** — whether a cap table is `available`, `not_applicable` (the legal form does not file Gesellschafterlisten), or `not_filed` (expected but missing)\n- **Shareholders** — each participant with share count, percentage of capital, and share class\n- **As-of metadata** — filing date of the aggregated list\n- **`fetch_realtime`** — optional. When `true`, scrape the latest Liste der\n Gesellschafter from the German registries and re-parse it before returning.\n Adds upstream latency (typically several seconds). Scraping or parsing\n failures are surfaced via `freshness.realtime_fetching_status`; the response still\n falls back to the most recent indexed cap table when a refresh fails.\n\nHistorical snapshots and cross-filing diffs are reserved for a later release.\n\nA response without a cap table (`coverage.status` other than `available`)\ncosts no credits.\n\n\n## Pricing\n\nSee the [pricing page](https://firmendata.com/en/plans) for this endpoint's credit cost.", + "description": "Latest aggregated Gesellschafterliste (cap table) for a GmbH or UG.\n\n- **Coverage status** — whether a cap table is `available`, `not_applicable` (the legal form does not file Gesellschafterlisten), or `not_filed` (expected but missing)\n- **Shareholders** — each participant with share count, percentage of capital, and share class\n- **As-of metadata** — filing date of the aggregated list\n- **`fetch_realtime`** — optional. When `true`, retrieve the current shareholder list from the German register before returning.\n Adds upstream latency (typically several seconds). Refresh failures are surfaced via `freshness.realtime_fetching_status`; the response still\n falls back to the most recent available cap table when a refresh fails.\n\nHistorical snapshots and cross-filing diffs are reserved for a later release.\n\nA response without a cap table (`coverage.status` other than `available`)\ncosts no credits.\n\n\n## Pricing\n\nSee the [pricing page](https://firmendata.com/en/plans) for this endpoint's credit cost.", "operationId": "getCompanyShareholders", "parameters": [ { @@ -10930,13 +11129,13 @@ } }, { - "description": "Only affects German (DE) companies; ignored for Swiss (CH) and Austrian (AT) companies, with no realtime surcharge. When `true`, scrape the Liste der Gesellschafter from the German registries and re-parse it before responding. Adds upstream latency (typically several seconds). Scraping failures are surfaced via `freshness.realtime_fetching_status` rather than failing the request.", + "description": "Only affects German (DE) companies; ignored for Swiss (CH) and Austrian (AT) companies, with no realtime surcharge. When `true`, retrieve the current shareholder list from the German register before responding. Adds latency (typically several seconds). Refresh failures are surfaced via `freshness.realtime_fetching_status` rather than failing the request.", "in": "query", "name": "fetch_realtime", "required": false, "schema": { "default": false, - "description": "Only affects German (DE) companies; ignored for Swiss (CH) and Austrian (AT) companies, with no realtime surcharge. When `true`, scrape the Liste der Gesellschafter from the German registries and re-parse it before responding. Adds upstream latency (typically several seconds). Scraping failures are surfaced via `freshness.realtime_fetching_status` rather than failing the request.", + "description": "Only affects German (DE) companies; ignored for Swiss (CH) and Austrian (AT) companies, with no realtime surcharge. When `true`, retrieve the current shareholder list from the German register before responding. Adds latency (typically several seconds). Refresh failures are surfaced via `freshness.realtime_fetching_status` rather than failing the request.", "title": "Fetch Realtime", "type": "boolean" } @@ -10968,6 +11167,7 @@ "example": { "detail": "A valid `Authorization: Bearer ` header is required.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "A valid `Authorization: Bearer ` header is required.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 401, "title": "Authentication required.", @@ -10980,12 +11180,36 @@ }, "description": "Missing, invalid, revoked, or expired bearer token." }, + "402": { + "content": { + "application/problem+json": { + "examples": { + "insufficient_credits": { + "value": { + "detail": "Not enough credits for this call; top up or upgrade your plan", + "instance": "/v1/companies/search", + "message": "Not enough credits for this call; top up or upgrade your plan", + "request_id": "req_01HXYZABC123DEF456GHI789J", + "status": 402, + "title": "Insufficient credits.", + "type": "https://api.firmendata.com/problems/insufficient-credits" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not enough credits for this call; top up or upgrade your plan" + }, "404": { "content": { "application/problem+json": { "example": { "detail": "No company with id DEF1103R.HRB999999B.", "instance": "/v1/companies/DEF1103R.HRB999999B", + "message": "No company with id DEF1103R.HRB999999B.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 404, "title": "Resource not found.", @@ -11010,6 +11234,7 @@ } ], "instance": "/v1/companies/search", + "message": "limit: Input should be less than or equal to 50", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 422, "title": "Request validation failed.", @@ -11028,6 +11253,7 @@ "example": { "detail": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "instance": "/v1/companies/autocomplete", + "message": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 429, "title": "Too many requests.", @@ -11075,6 +11301,7 @@ "example": { "detail": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 500, "title": "Internal server error.", @@ -11113,7 +11340,7 @@ }, "/v1/companies/{eu_id}/ubo": { "get": { - "description": "Walk the company's ownership chain and identify the natural persons\nwho ultimately benefit from it.\n\nFor every German GmbH/UG encountered in the chain, the latest filed\nGesellschafterliste is read; recursion stops at natural persons,\nforeign organisations, or any organisation without a German\nGesellschafterliste obligation. Each branch carries its direct share\npercentage. Beneficial-owner attribution follows the German\n\"all-or-nothing\" rule: each link in the chain is independently checked\nagainst the 25 % control threshold (covering both >50 % majority\ncontrol and >25 % Sperrminorität under the BVA's post-TraFinG\nreading of §3 Abs. 2 Nr. 3 GwG). The share attributed to a UBO is\nthe share at the final hop into the root — the actual capital stake\nthe UBO ultimately controls, not the multiplicative product.\n\n- **Graph** — every node and directed `source → target` edge\n traversed during the walk. Use it to render an ownership tree or to\n audit which branches stop short.\n- **Beneficial owners** — natural persons attributed more than 25 %\n of the root via direct holdings, controlled chains, or the sum of\n both, sorted descending. Empty when no person crosses the threshold\n (e.g. ownership diluted across many parties or the chain ends in\n unresolved organisations). Fictional UBO under §3 Abs. 2 S. 5 GwG\n is not surfaced.\n- **Potential beneficial owners** — unresolved organisations whose\n summed effective share at the root crosses 25 % under the same\n all-or-nothing rule. The natural person(s) controlling each entity\n are candidate UBOs that we couldn't trace ourselves. Same-entity\n dedup across multiple chain positions is by `(name, registered_\n seat, country_code)`, so an org appearing twice with 15 % + 15 %\n surfaces as one entry at 30 %.\n- **Coverage** — `coverage.status` distinguishes four states:\n `available` (chain fully resolved, OR remaining unresolved\n branches sum to ≤25 % effective share and cannot hide a UBO);\n `partial` (an unresolved branch could hide a beneficial owner —\n read `potential_beneficial_owners` and `coverage.reason`);\n `not_filed` (no cap table on file); `not_applicable` (legal form\n has no Gesellschafterliste obligation).\n- **Completeness** — `has_unresolved_branches` flags any non-root\n unresolved node in the graph; whether that compromises the answer\n is encoded in `coverage.status` (`partial` if so, otherwise\n `available` with `coverage.reason` explaining why the unresolved\n branches are immaterial).\n- **`fetch_realtime`** — optional. When `true`, refetch the latest\n Liste der Gesellschafter for every German GmbH/UG in the chain\n before building the graph. Adds noticeable latency (several seconds\n per company in the chain). Failures are surfaced via\n `freshness.realtime_fetching_status`; the graph still falls back to the most\n recent indexed cap tables when a refresh fails.\n\n**Edge cases worth knowing:**\n\n- **Control passes through 100 %, not the chain product.** A holds\n 60 % of H, H holds 30 % of root → A is a UBO with\n `effective_share_percent = 30`, not `18`. Each link is checked\n independently against the 25 % threshold.\n- **Sperrminorität counts as control.** A 26 % blocking minority at\n any intermediate level continues the chain; the 50 % majority bar\n only matters as the higher of the two ways to qualify.\n- **Sub-threshold links break the chain.** A holds 15 % of H, H\n holds 30 % of root → A is **not** attributed root ownership; H\n isn't controlled by A.\n- **Direct positions aggregate even below 25 %.** A person holding\n 15 % + 15 % directly in the root is a UBO at 30 %.\n- **Multiple controlled branches sum.** A controls X and Y; X holds\n 30 %, Y holds 40 % of root → A's `effective_share_percent = 70`\n (each branch is independently a control link, and the root-edge\n shares add up).\n\nDesigned for KYC workflows where the freshest possible chain matters.\n\nA response with `coverage.status` `not_filed` or `not_applicable` carries no\nownership data and costs no credits.\n\n\n## Pricing\n\nSee the [pricing page](https://firmendata.com/en/plans) for this endpoint's credit cost.", + "description": "Walk the company's ownership chain and identify the natural persons\nwho ultimately benefit from it.\n\nFor every German GmbH/UG encountered in the chain, the latest filed\nGesellschafterliste is read; recursion stops at natural persons,\nforeign organisations, or any organisation without a German\nGesellschafterliste obligation. Each branch carries its direct share\npercentage. Beneficial-owner attribution follows the German\n\"all-or-nothing\" rule: each link in the chain is independently checked\nagainst the 25 % control threshold (covering both >50 % majority\ncontrol and >25 % Sperrminorität under the BVA's post-TraFinG\nreading of §3 Abs. 2 Nr. 3 GwG). The share attributed to a UBO is\nthe share at the final hop into the root — the actual capital stake\nthe UBO ultimately controls, not the multiplicative product.\n\n- **Graph** — every node and directed `source → target` edge\n traversed during the walk. Use it to render an ownership tree or to\n audit which branches stop short.\n- **Beneficial owners** — natural persons attributed more than 25 %\n of the root via direct holdings, controlled chains, or the sum of\n both, sorted descending. Empty when no person crosses the threshold\n (e.g. ownership diluted across many parties or the chain ends in\n unresolved organisations). Fictional UBO under §3 Abs. 2 S. 5 GwG\n is not surfaced.\n- **Potential beneficial owners** — unresolved organisations whose\n summed effective share at the root crosses 25 % under the same\n all-or-nothing rule. The natural person(s) controlling each entity\n are candidate UBOs that we couldn't trace ourselves. Same-entity\n dedup across multiple chain positions is by `(name, registered_\n seat, country_code)`, so an org appearing twice with 15 % + 15 %\n surfaces as one entry at 30 %.\n- **Coverage** — `coverage.status` distinguishes four states:\n `available` (chain fully resolved, OR remaining unresolved\n branches sum to ≤25 % effective share and cannot hide a UBO);\n `partial` (an unresolved branch could hide a beneficial owner —\n read `potential_beneficial_owners` and `coverage.reason`);\n `not_filed` (no cap table on file); `not_applicable` (legal form\n has no Gesellschafterliste obligation).\n- **Completeness** — `has_unresolved_branches` flags any non-root\n unresolved node in the graph; whether that compromises the answer\n is encoded in `coverage.status` (`partial` if so, otherwise\n `available` with `coverage.reason` explaining why the unresolved\n branches are immaterial).\n- **`fetch_realtime`** — optional. When `true`, refetch the latest\n Liste der Gesellschafter for every German GmbH/UG in the chain\n before building the graph. Adds noticeable latency (several seconds\n per company in the chain). Failures are surfaced via\n `freshness.realtime_fetching_status`; the graph still falls back to the most\n recent available cap tables when a refresh fails.\n\n**Edge cases worth knowing:**\n\n- **Control passes through 100 %, not the chain product.** A holds\n 60 % of H, H holds 30 % of root → A is a UBO with\n `effective_share_percent = 30`, not `18`. Each link is checked\n independently against the 25 % threshold.\n- **Sperrminorität counts as control.** A 26 % blocking minority at\n any intermediate level continues the chain; the 50 % majority bar\n only matters as the higher of the two ways to qualify.\n- **Sub-threshold links break the chain.** A holds 15 % of H, H\n holds 30 % of root → A is **not** attributed root ownership; H\n isn't controlled by A.\n- **Direct positions aggregate even below 25 %.** A person holding\n 15 % + 15 % directly in the root is a UBO at 30 %.\n- **Multiple controlled branches sum.** A controls X and Y; X holds\n 30 %, Y holds 40 % of root → A's `effective_share_percent = 70`\n (each branch is independently a control link, and the root-edge\n shares add up).\n\nDesigned for KYC workflows where the freshest possible chain matters.\n\nA response with `coverage.status` `not_filed` or `not_applicable` carries no\nownership data and costs no credits.\n\n\n## Pricing\n\nSee the [pricing page](https://firmendata.com/en/plans) for this endpoint's credit cost.", "operationId": "getCompanyUbo", "parameters": [ { @@ -11171,6 +11398,7 @@ "example": { "detail": "A valid `Authorization: Bearer ` header is required.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "A valid `Authorization: Bearer ` header is required.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 401, "title": "Authentication required.", @@ -11183,12 +11411,36 @@ }, "description": "Missing, invalid, revoked, or expired bearer token." }, + "402": { + "content": { + "application/problem+json": { + "examples": { + "insufficient_credits": { + "value": { + "detail": "Not enough credits for this call; top up or upgrade your plan", + "instance": "/v1/companies/search", + "message": "Not enough credits for this call; top up or upgrade your plan", + "request_id": "req_01HXYZABC123DEF456GHI789J", + "status": 402, + "title": "Insufficient credits.", + "type": "https://api.firmendata.com/problems/insufficient-credits" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not enough credits for this call; top up or upgrade your plan" + }, "404": { "content": { "application/problem+json": { "example": { "detail": "No company with id DEF1103R.HRB999999B.", "instance": "/v1/companies/DEF1103R.HRB999999B", + "message": "No company with id DEF1103R.HRB999999B.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 404, "title": "Resource not found.", @@ -11213,6 +11465,7 @@ } ], "instance": "/v1/companies/search", + "message": "limit: Input should be less than or equal to 50", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 422, "title": "Request validation failed.", @@ -11231,6 +11484,7 @@ "example": { "detail": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "instance": "/v1/companies/autocomplete", + "message": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 429, "title": "Too many requests.", @@ -11278,6 +11532,7 @@ "example": { "detail": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 500, "title": "Internal server error.", @@ -11316,8 +11571,8 @@ }, "/v1/feeds/insolvencies": { "get": { - "description": "**Beta.** Poll company events in discovery order. On the first\ncall, `since` defaults to the last day; it must be within the last seven days.\nPass `next_cursor` back unchanged, including after an empty page. Cursors\npersist across deploys and restarts. A discovery position older than seven\ndays expires; restart with a recent `since`.\nRows are released after a settlement delay. `has_more=true` means continue\npaging immediately; otherwise poll again later. Filters use the alert feeds'\nmatching rules. A reparse can resurface an event id: clients must deduplicate\non `id`. Only company information is returned. A page with events costs one\ncredit; an empty page costs zero credits. An API key is required. Schuldenruf notices are included only when selected by name.\n\n## Pricing\n\nSee the [pricing page](https://firmendata.com/en/plans) for this endpoint's credit cost.", - "operationId": "poll_insolvencies_v1_feeds_insolvencies_get", + "description": "**Beta.** Poll company events in discovery order. On the first\ncall, `since` defaults to the last day; it must be within the last seven days.\nPass `next_cursor` back unchanged, including after an empty page. Cursors\npersist across deploys and restarts. A discovery position older than seven\ndays expires; restart with a recent `since`.\nEvents become available after a short delay. `has_more=true` means continue\npaging immediately; otherwise poll again later. The same event may appear\nmore than once: clients must deduplicate on `id`. Only company information is returned. A page with events costs one\ncredit; an empty page costs zero credits. An API key is required. Schuldenruf notices are included only when selected by name.\n\n## Pricing\n\nSee the [pricing page](https://firmendata.com/en/plans) for this endpoint's credit cost.", + "operationId": "pollInsolvencies", "parameters": [ { "description": "Persistent discovery cursor from the previous response's next_cursor.", @@ -11572,6 +11827,7 @@ "example": { "detail": "A valid `Authorization: Bearer ` header is required.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "A valid `Authorization: Bearer ` header is required.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 401, "title": "Authentication required.", @@ -11584,6 +11840,29 @@ }, "description": "Missing, invalid, revoked, or expired bearer token." }, + "402": { + "content": { + "application/problem+json": { + "examples": { + "insufficient_credits": { + "value": { + "detail": "Not enough credits for this call; top up or upgrade your plan", + "instance": "/v1/companies/search", + "message": "Not enough credits for this call; top up or upgrade your plan", + "request_id": "req_01HXYZABC123DEF456GHI789J", + "status": 402, + "title": "Insufficient credits.", + "type": "https://api.firmendata.com/problems/insufficient-credits" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not enough credits for this call; top up or upgrade your plan" + }, "422": { "content": { "application/problem+json": { @@ -11596,6 +11875,7 @@ } ], "instance": "/v1/companies/search", + "message": "limit: Input should be less than or equal to 50", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 422, "title": "Request validation failed.", @@ -11614,6 +11894,7 @@ "example": { "detail": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "instance": "/v1/companies/autocomplete", + "message": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 429, "title": "Too many requests.", @@ -11661,6 +11942,7 @@ "example": { "detail": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 500, "title": "Internal server error.", @@ -11699,8 +11981,8 @@ }, "/v1/feeds/new-companies": { "get": { - "description": "**Beta.** Poll company events in discovery order. On the first\ncall, `since` defaults to the last day; it must be within the last seven days.\nPass `next_cursor` back unchanged, including after an empty page. Cursors\npersist across deploys and restarts. A discovery position older than seven\ndays expires; restart with a recent `since`.\nRows are released after a settlement delay. `has_more=true` means continue\npaging immediately; otherwise poll again later. Filters use the alert feeds'\nmatching rules. A reparse can resurface an event id: clients must deduplicate\non `id`. Only company information is returned. A page with events costs one\ncredit; an empty page costs zero credits. An API key is required. Swiss companies appear only when discovered by the SHAB new-company scanner.\n\n## Pricing\n\nSee the [pricing page](https://firmendata.com/en/plans) for this endpoint's credit cost.", - "operationId": "poll_new_companies_v1_feeds_new_companies_get", + "description": "**Beta.** Poll company events in discovery order. On the first\ncall, `since` defaults to the last day; it must be within the last seven days.\nPass `next_cursor` back unchanged, including after an empty page. Cursors\npersist across deploys and restarts. A discovery position older than seven\ndays expires; restart with a recent `since`.\nEvents become available after a short delay. `has_more=true` means continue\npaging immediately; otherwise poll again later. The same event may appear\nmore than once: clients must deduplicate on `id`. Only company information is returned. A page with events costs one\ncredit; an empty page costs zero credits. An API key is required. Swiss events cover newly registered companies announced in the Swiss Official Gazette of Commerce (SHAB).\n\n## Pricing\n\nSee the [pricing page](https://firmendata.com/en/plans) for this endpoint's credit cost.", + "operationId": "pollNewCompanies", "parameters": [ { "description": "Persistent discovery cursor from the previous response's next_cursor.", @@ -11903,6 +12185,7 @@ "example": { "detail": "A valid `Authorization: Bearer ` header is required.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "A valid `Authorization: Bearer ` header is required.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 401, "title": "Authentication required.", @@ -11915,6 +12198,29 @@ }, "description": "Missing, invalid, revoked, or expired bearer token." }, + "402": { + "content": { + "application/problem+json": { + "examples": { + "insufficient_credits": { + "value": { + "detail": "Not enough credits for this call; top up or upgrade your plan", + "instance": "/v1/companies/search", + "message": "Not enough credits for this call; top up or upgrade your plan", + "request_id": "req_01HXYZABC123DEF456GHI789J", + "status": 402, + "title": "Insufficient credits.", + "type": "https://api.firmendata.com/problems/insufficient-credits" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not enough credits for this call; top up or upgrade your plan" + }, "422": { "content": { "application/problem+json": { @@ -11927,6 +12233,7 @@ } ], "instance": "/v1/companies/search", + "message": "limit: Input should be less than or equal to 50", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 422, "title": "Request validation failed.", @@ -11945,6 +12252,7 @@ "example": { "detail": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "instance": "/v1/companies/autocomplete", + "message": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 429, "title": "Too many requests.", @@ -11992,6 +12300,7 @@ "example": { "detail": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 500, "title": "Internal server error.", @@ -12058,6 +12367,7 @@ "example": { "detail": "A valid `Authorization: Bearer ` header is required.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "A valid `Authorization: Bearer ` header is required.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 401, "title": "Authentication required.", @@ -12082,6 +12392,7 @@ } ], "instance": "/v1/companies/search", + "message": "limit: Input should be less than or equal to 50", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 422, "title": "Request validation failed.", @@ -12100,6 +12411,7 @@ "example": { "detail": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "instance": "/v1/companies/autocomplete", + "message": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 429, "title": "Too many requests.", @@ -12147,6 +12459,7 @@ "example": { "detail": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 500, "title": "Internal server error.", @@ -12236,7 +12549,7 @@ } }, { - "description": "Order the list by a column instead of by when companies were added. Same vocabulary as `/companies/search`.", + "description": "Order the list by a company attribute instead of by when companies were added. Same vocabulary as `/companies/search`.", "in": "query", "name": "sort", "required": false, @@ -12249,7 +12562,7 @@ "type": "null" } ], - "description": "Order the list by a column instead of by when companies were added. Same vocabulary as `/companies/search`.", + "description": "Order the list by a company attribute instead of by when companies were added. Same vocabulary as `/companies/search`.", "title": "Sort" } }, @@ -12291,6 +12604,7 @@ "example": { "detail": "A valid `Authorization: Bearer ` header is required.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "A valid `Authorization: Bearer ` header is required.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 401, "title": "Authentication required.", @@ -12309,6 +12623,7 @@ "example": { "detail": "No company with id DEF1103R.HRB999999B.", "instance": "/v1/companies/DEF1103R.HRB999999B", + "message": "No company with id DEF1103R.HRB999999B.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 404, "title": "Resource not found.", @@ -12333,6 +12648,7 @@ } ], "instance": "/v1/companies/search", + "message": "limit: Input should be less than or equal to 50", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 422, "title": "Request validation failed.", @@ -12351,6 +12667,7 @@ "example": { "detail": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "instance": "/v1/companies/autocomplete", + "message": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 429, "title": "Too many requests.", @@ -12398,6 +12715,7 @@ "example": { "detail": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 500, "title": "Internal server error.", @@ -12566,6 +12884,7 @@ "example": { "detail": "A valid `Authorization: Bearer ` header is required.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "A valid `Authorization: Bearer ` header is required.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 401, "title": "Authentication required.", @@ -12590,6 +12909,7 @@ } ], "instance": "/v1/companies/search", + "message": "limit: Input should be less than or equal to 50", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 422, "title": "Request validation failed.", @@ -12608,6 +12928,7 @@ "example": { "detail": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "instance": "/v1/companies/autocomplete", + "message": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 429, "title": "Too many requests.", @@ -12655,6 +12976,7 @@ "example": { "detail": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 500, "title": "Internal server error.", @@ -12741,6 +13063,7 @@ "example": { "detail": "A valid `Authorization: Bearer ` header is required.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "A valid `Authorization: Bearer ` header is required.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 401, "title": "Authentication required.", @@ -12753,12 +13076,36 @@ }, "description": "Missing, invalid, revoked, or expired bearer token." }, + "402": { + "content": { + "application/problem+json": { + "examples": { + "insufficient_credits": { + "value": { + "detail": "Not enough credits for this call; top up or upgrade your plan", + "instance": "/v1/companies/search", + "message": "Not enough credits for this call; top up or upgrade your plan", + "request_id": "req_01HXYZABC123DEF456GHI789J", + "status": 402, + "title": "Insufficient credits.", + "type": "https://api.firmendata.com/problems/insufficient-credits" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not enough credits for this call; top up or upgrade your plan" + }, "404": { "content": { "application/problem+json": { "example": { "detail": "No company with id DEF1103R.HRB999999B.", "instance": "/v1/companies/DEF1103R.HRB999999B", + "message": "No company with id DEF1103R.HRB999999B.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 404, "title": "Resource not found.", @@ -12777,6 +13124,7 @@ "example": { "detail": "A subscription for this company, type, and notification channel already exists.", "instance": "/v1/subscriptions", + "message": "A subscription for this company, type, and notification channel already exists.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 409, "title": "Conflict.", @@ -12801,6 +13149,7 @@ } ], "instance": "/v1/companies/search", + "message": "limit: Input should be less than or equal to 50", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 422, "title": "Request validation failed.", @@ -12819,6 +13168,7 @@ "example": { "detail": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "instance": "/v1/companies/autocomplete", + "message": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 429, "title": "Too many requests.", @@ -12866,6 +13216,7 @@ "example": { "detail": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 500, "title": "Internal server error.", @@ -12945,6 +13296,7 @@ "example": { "detail": "A valid `Authorization: Bearer ` header is required.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "A valid `Authorization: Bearer ` header is required.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 401, "title": "Authentication required.", @@ -12963,6 +13315,7 @@ "example": { "detail": "No company with id DEF1103R.HRB999999B.", "instance": "/v1/companies/DEF1103R.HRB999999B", + "message": "No company with id DEF1103R.HRB999999B.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 404, "title": "Resource not found.", @@ -12987,6 +13340,7 @@ } ], "instance": "/v1/companies/search", + "message": "limit: Input should be less than or equal to 50", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 422, "title": "Request validation failed.", @@ -13005,6 +13359,7 @@ "example": { "detail": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "instance": "/v1/companies/autocomplete", + "message": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 429, "title": "Too many requests.", @@ -13052,6 +13407,7 @@ "example": { "detail": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 500, "title": "Internal server error.", @@ -13090,7 +13446,7 @@ }, "/v1/subscriptions/events/{event_id}/resend": { "post": { - "description": "Re-sends a previously delivered webhook event to the subscription's current `webhook_url`. The body is cloned from the stored event verbatim; only `event_id` and `delivered_at` are refreshed so the receiver sees a distinct delivery it can dedupe on. Records a new event row — the original is left untouched.\n\nOnly valid for webhook subscriptions whose original event has a stored `request_body`. Email events, async (poll-only) events, and rows that predate request-body persistence return 422 — there's nothing to re-POST.\n\n## Pricing\n\nSee the [pricing page](https://firmendata.com/en/plans) for this endpoint's credit cost.", + "description": "Re-sends a previously delivered webhook event to the subscription's current `webhook_url`. The body is cloned from the stored event verbatim; only `event_id` and `delivered_at` are refreshed so the receiver sees a distinct delivery it can dedupe on. Creates a new event — the original is left untouched.\n\nOnly valid for webhook subscriptions whose original event has a stored `request_body`. Email events, async (poll-only) events, and older events without recorded request bodies return 422 — there's nothing to re-POST.\n\n## Pricing\n\nSee the [pricing page](https://firmendata.com/en/plans) for this endpoint's credit cost.", "operationId": "resendEvent", "parameters": [ { @@ -13131,6 +13487,7 @@ "example": { "detail": "A valid `Authorization: Bearer ` header is required.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "A valid `Authorization: Bearer ` header is required.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 401, "title": "Authentication required.", @@ -13149,6 +13506,7 @@ "example": { "detail": "No company with id DEF1103R.HRB999999B.", "instance": "/v1/companies/DEF1103R.HRB999999B", + "message": "No company with id DEF1103R.HRB999999B.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 404, "title": "Resource not found.", @@ -13173,6 +13531,7 @@ } ], "instance": "/v1/companies/search", + "message": "limit: Input should be less than or equal to 50", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 422, "title": "Request validation failed.", @@ -13191,6 +13550,7 @@ "example": { "detail": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "instance": "/v1/companies/autocomplete", + "message": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 429, "title": "Too many requests.", @@ -13238,6 +13598,7 @@ "example": { "detail": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 500, "title": "Internal server error.", @@ -13276,7 +13637,7 @@ }, "/v1/subscriptions/test": { "post": { - "description": "Send one **real** sample notification — webhook **or** email — using\nthe same rendering, signing, and delivery path as production. **No\nsubscription is created**, no DB row is written, and no scrape runs.\nSafe and cheap to call repeatedly from your CI / integration tests.\n\nPick the transport via `notification_type`:\n\n* **`webhook`** — POSTs a signed JSON body to `webhook_url`. The\n request is always signed with the documented public test secret\n `firmendata_test_secret_do_not_use_in_production`. The HMAC recipe\n is identical to production, so any verification code that matches\n with this constant will also match a real per-subscription secret.\n Hard-code it in your unit tests; never reuse it for real\n subscriptions.\n* **`email`** — composes the bilingual change-notification email\n (subject + HTML body) and sends it to `recipient_email`. Subject\n is prefixed with `[TEST]`; the body carries an explicit \"this is a\n test\" banner so a human reading the inbox can't mistake it for a\n live update. For `doc_*` subscription types the response also\n attaches a tiny on-the-fly **sample PDF** so the customer's\n attachment-handling pipeline (parse Content-Type, save to disk,\n forward to S3, …) can be exercised end to end. In production the\n attachment is the real register PDF.\n\nNo retries — this is a fail-fast probe. The receiver's status, error\ndetail, and the exact bytes / headers / subject / body firmendata\nsent are all returned, so you can diff them against your verification\nroutine offline if your endpoint isn't yet live.\n\n`subscription_type` controls the payload shape, summary copy, and —\nfor `doc_*` types — the embedded download URL / file metadata block,\nidentical to what a real notification carries. `eu_id` is embedded in\nthe payload but does **not** need to be known to firmendata.\n\n## Webhook payload faithfulness\n\nTop-level `publish_date` and `links` are present on every delivery.\nThe per-type `data` block matches a production change delivery for\nthe same `subscription_type`:\n\n* Parsed-data types (`details`, `history`, `shareholders`, `ubo`)\n deliver the **same response shape as the corresponding\n `GET /v1/companies/{eu_id}[/...]` endpoint**. When the\n `eu_id` you pass is known to firmendata the test endpoint embeds the\n real current data; when it does not, `data` is a sparse placeholder\n containing only `{eu_id}` — the field set is what varies, the schema\n does not.\n* `doc_*` types deliver `{url, expires_at, file_id, file_type,\n fetched_at}` directly under `data`. In test mode the `url` points at\n the v1 documents endpoint (clearly identifiable as a test artifact);\n in production it is a 7-day presigned URL to the real document.\n\nThe only top-level difference between test and production is an extra\n`_meta: {\"test\": true}` key — receivers that ignore unknown top-level\nkeys (recommended) need no test branch at all. The `subscription_id`\nis a freshly-minted UUID, not a real id; treat it as opaque.\n\n## Integration-test scenarios\n\nWebhook only — pick via `delivery_scenario`:\n\n* `success` (default) — single signed POST. Receiver should return 2xx.\n* `retry_delivery` — two POSTs back-to-back with byte-identical body,\n signature, timestamp, and `X-Firmendata-Delivery-Id`. Mirrors how\n production retries look after a 5xx so you can verify your\n idempotent dedupe.\n* `bad_signature` — single POST signed with a wrong secret. Your\n endpoint SHOULD reject with 4xx; that is the test passing.\n* `stale_timestamp` — single POST whose `X-Firmendata-Timestamp` is\n 10 minutes in the past. Your endpoint SHOULD reject with 4xx; that\n is the test passing.\n\nPer-attempt detail (status, signature, timestamp, response excerpt)\nis returned in `attempts[]` regardless of scenario.\n\n## Sample receiver\n\nA minimal listener that verifies the signature and prints the body —\ndrop into your test harness, point your `webhook_url` at it, and call\nthis endpoint to drive it.\n\n### Python (Flask)\n\n```python\n# pip install flask\nimport hashlib\nimport hmac\nimport json\nfrom flask import Flask, abort, request\n\nWEBHOOK_SECRET = \"firmendata_test_secret_do_not_use_in_production\"\nMAX_TIMESTAMP_SKEW_SECONDS = 5 * 60\n\napp = Flask(__name__)\n\n\n@app.post(\"/firmendata/webhook\")\ndef receive_webhook():\n raw = request.get_data()\n sig = request.headers.get(\"X-Firmendata-Signature\", \"\")\n ts = request.headers.get(\"X-Firmendata-Timestamp\", \"\")\n\n expected = \"sha256=\" + hmac.new(\n WEBHOOK_SECRET.encode(),\n f\"{ts}.\".encode() + raw,\n hashlib.sha256,\n ).hexdigest()\n if not hmac.compare_digest(sig, expected):\n abort(401)\n\n # Replay protection — reject anything older than 5 minutes.\n import time\n if abs(int(time.time()) - int(ts or 0)) > MAX_TIMESTAMP_SKEW_SECONDS:\n abort(401)\n\n payload = json.loads(raw)\n print(\n f\"[{payload['event_type']}] eu_id={payload['eu_id']} \"\n f\"delivery_id={request.headers['X-Firmendata-Delivery-Id']}\"\n )\n print(json.dumps(payload, indent=2))\n return (\"\", 200)\n\n\nif __name__ == \"__main__\":\n app.run(port=8080)\n```\n\n### Node.js (Express)\n\n```js\n// npm install express\nimport crypto from \"node:crypto\";\nimport express from \"express\";\n\nconst WEBHOOK_SECRET = \"firmendata_test_secret_do_not_use_in_production\";\nconst MAX_TIMESTAMP_SKEW_SECONDS = 5 * 60;\n\nconst app = express();\n\napp.post(\n \"/firmendata/webhook\",\n express.raw({ type: \"application/json\" }),\n (req, res) => {\n const sig = req.header(\"X-Firmendata-Signature\") ?? \"\";\n const ts = req.header(\"X-Firmendata-Timestamp\") ?? \"\";\n\n const expected =\n \"sha256=\" +\n crypto\n .createHmac(\"sha256\", WEBHOOK_SECRET)\n .update(`${ts}.${req.body.toString()}`)\n .digest(\"hex\");\n const sigBuf = Buffer.from(sig);\n const expBuf = Buffer.from(expected);\n if (\n sigBuf.length !== expBuf.length ||\n !crypto.timingSafeEqual(sigBuf, expBuf)\n ) {\n return res.status(401).end();\n }\n\n if (Math.abs(Math.floor(Date.now() / 1000) - Number(ts || 0)) >\n MAX_TIMESTAMP_SKEW_SECONDS) {\n return res.status(401).end();\n }\n\n const payload = JSON.parse(req.body.toString());\n console.log(\n `[${payload.event_type}] eu_id=${payload.eu_id} ` +\n `delivery_id=${req.header(\"X-Firmendata-Delivery-Id\")}`\n );\n console.log(JSON.stringify(payload, null, 2));\n res.status(200).end();\n }\n);\n\napp.listen(8080);\n```\n\n\n## Pricing\n\nSee the [pricing page](https://firmendata.com/en/plans) for this endpoint's credit cost.", + "description": "Send one **real** sample notification — webhook **or** email — using\nthe same rendering, signing, and delivery path as production. **No\nsubscription is created**, nothing is stored and no data is fetched.\nSafe and cheap to call repeatedly from your CI / integration tests.\n\nPick the transport via `notification_type`:\n\n* **`webhook`** — POSTs a signed JSON body to `webhook_url`. The\n request is always signed with the documented public test secret\n `firmendata_test_secret_do_not_use_in_production`. The HMAC recipe\n is identical to production, so any verification code that matches\n with this constant will also match a real per-subscription secret.\n Hard-code it in your unit tests; never reuse it for real\n subscriptions.\n* **`email`** — composes the bilingual change-notification email\n (subject + HTML body) and sends it to `recipient_email`. Subject\n is prefixed with `[TEST]`; the body carries an explicit \"this is a\n test\" banner so a human reading the inbox can't mistake it for a\n live update. For `doc_*` subscription types the response also\n attaches a tiny on-the-fly **sample PDF** so the customer's\n attachment handling (parse Content-Type, save to disk,\n forward to S3, …) can be exercised end to end. In production the\n attachment is the real register PDF.\n\nNo retries — this is a fail-fast probe. The receiver's status, error\ndetail, and the exact bytes / headers / subject / body firmendata\nsent are all returned, so you can diff them against your verification\nroutine offline if your endpoint isn't yet live.\n\n`subscription_type` controls the payload shape, summary copy, and —\nfor `doc_*` types — the embedded download URL / file metadata block,\nidentical to what a real notification carries. `eu_id` is embedded in\nthe payload but does **not** need to be known to firmendata.\n\n## Webhook payload faithfulness\n\nTop-level `publish_date` and `links` are present on every delivery.\nThe per-type `data` block matches a production change delivery for\nthe same `subscription_type`:\n\n* Parsed-data types (`details`, `history`, `shareholders`, `ubo`)\n deliver the **same response shape as the corresponding\n `GET /v1/companies/{eu_id}[/...]` endpoint**. When the\n `eu_id` you pass is known to firmendata the test endpoint embeds the\n real current data; when it does not, `data` is a sparse placeholder\n containing only `{eu_id}` — the field set is what varies, the schema\n does not.\n* `doc_*` types deliver `{url, expires_at, file_id, file_type,\n fetched_at}` directly under `data`. In test mode the `url` points at\n the v1 documents endpoint (clearly identifiable as a test artifact);\n in production it is a 7-day presigned URL to the real document.\n\nThe only top-level difference between test and production is an extra\n`_meta: {\"test\": true}` key — receivers that ignore unknown top-level\nkeys (recommended) need no test branch at all. The `subscription_id`\nis a freshly-minted UUID, not a real id; treat it as opaque.\n\n## Integration-test scenarios\n\nWebhook only — pick via `delivery_scenario`:\n\n* `success` (default) — single signed POST. Receiver should return 2xx.\n* `retry_delivery` — two POSTs back-to-back with byte-identical body,\n signature, timestamp, and `X-Firmendata-Delivery-Id`. Mirrors how\n production retries look after a 5xx so you can verify your\n idempotent dedupe.\n* `bad_signature` — single POST signed with a wrong secret. Your\n endpoint SHOULD reject with 4xx; that is the test passing.\n* `stale_timestamp` — single POST whose `X-Firmendata-Timestamp` is\n 10 minutes in the past. Your endpoint SHOULD reject with 4xx; that\n is the test passing.\n\nPer-attempt detail (status, signature, timestamp, response excerpt)\nis returned in `attempts[]` regardless of scenario.\n\n## Sample receiver\n\nA minimal listener that verifies the signature and prints the body —\ndrop into your test harness, point your `webhook_url` at it, and call\nthis endpoint to drive it.\n\n### Python (Flask)\n\n```python\n# pip install flask\nimport hashlib\nimport hmac\nimport json\nfrom flask import Flask, abort, request\n\nWEBHOOK_SECRET = \"firmendata_test_secret_do_not_use_in_production\"\nMAX_TIMESTAMP_SKEW_SECONDS = 5 * 60\n\napp = Flask(__name__)\n\n\n@app.post(\"/firmendata/webhook\")\ndef receive_webhook():\n raw = request.get_data()\n sig = request.headers.get(\"X-Firmendata-Signature\", \"\")\n ts = request.headers.get(\"X-Firmendata-Timestamp\", \"\")\n\n expected = \"sha256=\" + hmac.new(\n WEBHOOK_SECRET.encode(),\n f\"{ts}.\".encode() + raw,\n hashlib.sha256,\n ).hexdigest()\n if not hmac.compare_digest(sig, expected):\n abort(401)\n\n # Replay protection — reject anything older than 5 minutes.\n import time\n if abs(int(time.time()) - int(ts or 0)) > MAX_TIMESTAMP_SKEW_SECONDS:\n abort(401)\n\n payload = json.loads(raw)\n print(\n f\"[{payload['event_type']}] eu_id={payload['eu_id']} \"\n f\"delivery_id={request.headers['X-Firmendata-Delivery-Id']}\"\n )\n print(json.dumps(payload, indent=2))\n return (\"\", 200)\n\n\nif __name__ == \"__main__\":\n app.run(port=8080)\n```\n\n### Node.js (Express)\n\n```js\n// npm install express\nimport crypto from \"node:crypto\";\nimport express from \"express\";\n\nconst WEBHOOK_SECRET = \"firmendata_test_secret_do_not_use_in_production\";\nconst MAX_TIMESTAMP_SKEW_SECONDS = 5 * 60;\n\nconst app = express();\n\napp.post(\n \"/firmendata/webhook\",\n express.raw({ type: \"application/json\" }),\n (req, res) => {\n const sig = req.header(\"X-Firmendata-Signature\") ?? \"\";\n const ts = req.header(\"X-Firmendata-Timestamp\") ?? \"\";\n\n const expected =\n \"sha256=\" +\n crypto\n .createHmac(\"sha256\", WEBHOOK_SECRET)\n .update(`${ts}.${req.body.toString()}`)\n .digest(\"hex\");\n const sigBuf = Buffer.from(sig);\n const expBuf = Buffer.from(expected);\n if (\n sigBuf.length !== expBuf.length ||\n !crypto.timingSafeEqual(sigBuf, expBuf)\n ) {\n return res.status(401).end();\n }\n\n if (Math.abs(Math.floor(Date.now() / 1000) - Number(ts || 0)) >\n MAX_TIMESTAMP_SKEW_SECONDS) {\n return res.status(401).end();\n }\n\n const payload = JSON.parse(req.body.toString());\n console.log(\n `[${payload.event_type}] eu_id=${payload.eu_id} ` +\n `delivery_id=${req.header(\"X-Firmendata-Delivery-Id\")}`\n );\n console.log(JSON.stringify(payload, null, 2));\n res.status(200).end();\n }\n);\n\napp.listen(8080);\n```\n\n\n## Pricing\n\nSee the [pricing page](https://firmendata.com/en/plans) for this endpoint's credit cost.", "operationId": "testDelivery", "requestBody": { "content": { @@ -13314,6 +13675,7 @@ "example": { "detail": "A valid `Authorization: Bearer ` header is required.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "A valid `Authorization: Bearer ` header is required.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 401, "title": "Authentication required.", @@ -13338,6 +13700,7 @@ } ], "instance": "/v1/companies/search", + "message": "limit: Input should be less than or equal to 50", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 422, "title": "Request validation failed.", @@ -13356,6 +13719,7 @@ "example": { "detail": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "instance": "/v1/companies/autocomplete", + "message": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 429, "title": "Too many requests.", @@ -13403,6 +13767,7 @@ "example": { "detail": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 500, "title": "Internal server error.", @@ -13475,6 +13840,7 @@ "example": { "detail": "A valid `Authorization: Bearer ` header is required.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "A valid `Authorization: Bearer ` header is required.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 401, "title": "Authentication required.", @@ -13493,6 +13859,7 @@ "example": { "detail": "No company with id DEF1103R.HRB999999B.", "instance": "/v1/companies/DEF1103R.HRB999999B", + "message": "No company with id DEF1103R.HRB999999B.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 404, "title": "Resource not found.", @@ -13517,6 +13884,7 @@ } ], "instance": "/v1/companies/search", + "message": "limit: Input should be less than or equal to 50", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 422, "title": "Request validation failed.", @@ -13535,6 +13903,7 @@ "example": { "detail": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "instance": "/v1/companies/autocomplete", + "message": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 429, "title": "Too many requests.", @@ -13582,6 +13951,7 @@ "example": { "detail": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 500, "title": "Internal server error.", @@ -13659,6 +14029,7 @@ "example": { "detail": "A valid `Authorization: Bearer ` header is required.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "A valid `Authorization: Bearer ` header is required.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 401, "title": "Authentication required.", @@ -13677,6 +14048,7 @@ "example": { "detail": "No company with id DEF1103R.HRB999999B.", "instance": "/v1/companies/DEF1103R.HRB999999B", + "message": "No company with id DEF1103R.HRB999999B.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 404, "title": "Resource not found.", @@ -13701,6 +14073,7 @@ } ], "instance": "/v1/companies/search", + "message": "limit: Input should be less than or equal to 50", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 422, "title": "Request validation failed.", @@ -13719,6 +14092,7 @@ "example": { "detail": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "instance": "/v1/companies/autocomplete", + "message": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 429, "title": "Too many requests.", @@ -13766,6 +14140,7 @@ "example": { "detail": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 500, "title": "Internal server error.", @@ -13891,6 +14266,7 @@ "example": { "detail": "A valid `Authorization: Bearer ` header is required.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "A valid `Authorization: Bearer ` header is required.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 401, "title": "Authentication required.", @@ -13909,6 +14285,7 @@ "example": { "detail": "No company with id DEF1103R.HRB999999B.", "instance": "/v1/companies/DEF1103R.HRB999999B", + "message": "No company with id DEF1103R.HRB999999B.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 404, "title": "Resource not found.", @@ -13933,6 +14310,7 @@ } ], "instance": "/v1/companies/search", + "message": "limit: Input should be less than or equal to 50", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 422, "title": "Request validation failed.", @@ -13951,6 +14329,7 @@ "example": { "detail": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "instance": "/v1/companies/autocomplete", + "message": "Rate limit exceeded: more than 5 requests per 1 second. Retry in 1s.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 429, "title": "Too many requests.", @@ -13998,6 +14377,7 @@ "example": { "detail": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "instance": "/v1/companies/DEF1103R.HRB279792B", + "message": "An unexpected error occurred. Please retry later or contact support with the `request_id`.", "request_id": "req_01HXYZABC123DEF456GHI789J", "status": 500, "title": "Internal server error.", @@ -14042,7 +14422,7 @@ ], "servers": [ { - "description": "Production", + "description": "FirmenData API", "url": "https://api.firmendata.com" } ], @@ -14056,7 +14436,7 @@ "name": "Search" }, { - "description": "Company profile endpoints. `detail` returns the full hero profile with contact, people, LEI graph, and AI insights; `financials` returns multi-year financials, employee history, and parent/subsidiary references; `shareholders` returns the latest cap table; `ubo` walks the ownership chain to the natural-person beneficial owners; `history` returns the chronological register-entry timeline.", + "description": "Company profile endpoints. `detail` returns the full hero profile with contact, people, LEI graph, and company insights; `financials` returns multi-year financials, employee history, and parent/subsidiary references; `shareholders` returns the latest cap table; `ubo` walks the ownership chain to the natural-person beneficial owners; `history` returns the chronological register-entry timeline.", "name": "Company" }, { diff --git a/pyproject.toml b/pyproject.toml index 7fe9152..95c971f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,14 +5,14 @@ build-backend = "hatchling.build" [project] name = "firmendata" dynamic = ["version"] -description = "Official Python client for the firmendata German company-data API" +description = "Official Python client for the firmendata API: German, Swiss and Austrian company data" readme = "README.md" license = "MIT" requires-python = ">=3.11" authors = [{ name = "FirmenData", email = "info@firmendata.com" }] keywords = [ - "firmendata", "germany", "company-data", "handelsregister", - "unternehmensregister", "company-register", "kyc", "ubo", "api-client", + "firmendata", "germany", "switzerland", "austria", "company-data", "handelsregister", + "unternehmensregister", "firmenbuch", "company-register", "kyc", "ubo", "api-client", ] classifiers = [ "Development Status :: 4 - Beta", @@ -39,7 +39,12 @@ dev = [ "respx>=0.21", "mypy>=1.11", "ruff>=0.6", - "datamodel-code-generator>=0.25", + # The type generator is pinned exactly: its output (and black's formatting + # of it) changes between releases, and CI fails when regenerating + # src/firmendata/types.py produces any diff. Bump both together, regenerate, + # and commit the result. + "datamodel-code-generator==0.87.0", + "black==26.10.1", ] [project.urls] diff --git a/scripts/generate_types.py b/scripts/generate_types.py index b764f0e..cf79e28 100644 --- a/scripts/generate_types.py +++ b/scripts/generate_types.py @@ -40,6 +40,7 @@ ("/v1/subscriptions", "get"): "SubscriptionFilters", ("/v1/feeds/new-companies", "get"): "FeedNewCompaniesFilters", ("/v1/feeds/insolvencies", "get"): "FeedInsolvenciesFilters", + ("/v1/lists/{list_id}", "get"): "CompanyListFilters", } @@ -148,8 +149,8 @@ def main() -> int: if result.returncode != 0: sys.stderr.write(result.stderr) print( - "\ndatamodel-code-generator failed. Install it with " - "`pip install 'datamodel-code-generator[http]'`.", + "\ndatamodel-code-generator failed. Install the pinned toolchain with " + "`pip install -e '.[dev]'`.", file=sys.stderr, ) return result.returncode diff --git a/src/firmendata/__init__.py b/src/firmendata/__init__.py index ba83ea1..44723ad 100644 --- a/src/firmendata/__init__.py +++ b/src/firmendata/__init__.py @@ -1,4 +1,4 @@ -"""Official Python client for the firmendata German company-data API. +"""Official Python client for the firmendata API: German, Swiss and Austrian company data. Autocomplete is free and needs no API key:: @@ -7,13 +7,15 @@ for hit in FirmenData().autocomplete("siemens")["data"]: print(hit["display_name"], hit["eu_id"]) -Everything else needs a key from https://firmendata.com/de/account/api-keys:: +Everything else needs a key from https://firmendata.com/de/developer/api-keys:: fd = FirmenData(api_key="firmendata_live_...") - company = fd.get_company("DEB1103R_HRB123456") + company = fd.get_company("DEF1103R.HRB12300B") """ +from ._base import REALTIME_TIMEOUT, AppInfo, ResponseInfo from ._feeds import AsyncFeedIterator, FeedIterator +from ._pagination import SEARCH_RESULT_CAP from ._version import __version__ from .async_client import AsyncFirmenData from .client import FirmenData @@ -38,6 +40,10 @@ "AsyncFirmenData", "FeedIterator", "AsyncFeedIterator", + "AppInfo", + "ResponseInfo", + "REALTIME_TIMEOUT", + "SEARCH_RESULT_CAP", # errors "FirmenDataError", "APIError", diff --git a/src/firmendata/_base.py b/src/firmendata/_base.py index cdc9f65..c6b32b5 100644 --- a/src/firmendata/_base.py +++ b/src/firmendata/_base.py @@ -3,16 +3,25 @@ from __future__ import annotations import json as _json -from collections.abc import Iterable, Mapping, Sequence -from typing import Any +import re +import warnings +from collections.abc import Callable, Iterable, Mapping, Sequence +from dataclasses import dataclass +from typing import Any, NotRequired, TypedDict from urllib.parse import quote +import httpx + from ._retry import DEFAULT_MAX_RETRIES from ._version import __version__ -from .errors import build_error +from .errors import APIConnectionError, APITimeoutError DEFAULT_BASE_URL = "https://api.firmendata.com" DEFAULT_TIMEOUT = 30.0 +#: The minimum timeout, in seconds, for live calls — ``fetch_realtime=True`` +#: and document listings and downloads — which can take up to about two +#: minutes. Applies unless the call passes its own ``timeout``. +REALTIME_TIMEOUT = 150.0 ParamValue = str | int | float | bool | Sequence[str] | Sequence[int] | None @@ -21,6 +30,41 @@ #: would not be accepted where httpx wants this. QueryParamPairs = list[tuple[str, str | int | float | bool | None]] +_CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]") +_APP_INFO_MAX_LENGTH = {"name": 64, "version": 32, "url": 256} + +_LIVE_NOTE = ( + "Live calls (fetch_realtime=True, document listings and downloads) are not retried " + "once they may have reached the server: the server can still finish this one, and it " + "is billed when it does." +) + + +class AppInfo(TypedDict): + """Identifies your integration in the ``User-Agent``, like Stripe's ``app_info``.""" + + #: Your application or plugin name, e.g. ``"acme-crm"``. + name: str + version: NotRequired[str] + url: NotRequired[str] + + +@dataclass(frozen=True, slots=True) +class ResponseInfo: + """Metadata of one HTTP response, passed to ``on_response``.""" + + status: int + #: Response headers; lookups are case-insensitive. + headers: Mapping[str, str] + #: ``X-Request-Id`` — quote it to support. + request_id: str | None + #: ``X-Credits-Charged``: credits this call deducted. ``None`` when absent. + credits_charged: int | None + + +#: Called for every HTTP response. The async client also awaits a coroutine. +ResponseHook = Callable[[ResponseInfo], object] + def _segment(value: str) -> str: """Percent-encode a caller-supplied path segment. @@ -33,6 +77,57 @@ def _segment(value: str) -> str: return quote(value, safe="") +def _app_info_part(field: str, value: object) -> str | None: + if value is None: + return None + if not isinstance(value, str): + raise TypeError(f"app_info[{field!r}] must be a string") + # Control characters (CR/LF above all) would let the value break out of the header. + if _CONTROL_CHARS.search(value): + raise ValueError(f"app_info[{field!r}] must not contain line breaks or control characters") + return value.strip()[: _APP_INFO_MAX_LENGTH[field]] or None + + +def _user_agent(app_info: Mapping[str, Any] | None) -> str: + agent = f"firmendata-python/{__version__}" + if app_info is None: + return agent + name = _app_info_part("name", app_info.get("name")) + if not name: + raise ValueError("app_info['name'] must be a non-empty string") + version = _app_info_part("version", app_info.get("version")) + url = _app_info_part("url", app_info.get("url")) + agent += f" {name}" + if version: + agent += f"/{version}" + if url: + agent += f" ({url})" + return agent + + +def _request_sent(exc: httpx.TransportError) -> bool: + """Whether a failed request may have reached the server. + + Only failures to *establish* a connection prove it did not. + """ + return not isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout)) + + +def _financial_params( + details: bool | None, include_line_items: bool | None, years: int | None, +) -> dict[str, Any]: + if include_line_items is not None: + warnings.warn( + "include_line_items is deprecated: line items are included by default. " + "Pass details=False for the lean response.", + DeprecationWarning, + stacklevel=3, + ) + if details is None: + details = include_line_items + return {"details": details, "years": years} + + class _BaseClient: """URL, header and parameter handling. No I/O lives here.""" @@ -43,6 +138,8 @@ def __init__( base_url: str = DEFAULT_BASE_URL, timeout: float = DEFAULT_TIMEOUT, max_retries: int = DEFAULT_MAX_RETRIES, + app_info: AppInfo | None = None, + on_response: ResponseHook | None = None, ) -> None: #: ``None`` is legitimate: ``autocomplete()`` works without a key. #: Every other endpoint will raise ``AuthenticationError``. @@ -50,6 +147,8 @@ def __init__( self.base_url = base_url.rstrip("/") self.timeout = timeout self.max_retries = max(0, int(max_retries)) + self.on_response = on_response + self._user_agent = _user_agent(app_info) # -- request shaping --------------------------------------------------- @@ -57,14 +156,22 @@ def _url(self, path: str) -> str: return f"{self.base_url}{path}" def _headers(self) -> dict[str, str]: + """The one place request headers are built.""" headers = { "Accept": "application/json", - "User-Agent": f"firmendata-python/{__version__}", + "User-Agent": self._user_agent, + "X-FirmenData-Client": f"python-sdk/{__version__}", } if self.api_key: headers["Authorization"] = f"Bearer {self.api_key}" return headers + def _timeout_for(self, live: bool, timeout: float | None) -> float: + """An explicit per-call ``timeout`` wins; live calls wait at least 150 s.""" + if timeout is not None: + return timeout + return max(self.timeout, REALTIME_TIMEOUT) if live else self.timeout + @staticmethod def _params(raw: Mapping[str, ParamValue]) -> QueryParamPairs: """Flatten query parameters, dropping ``None``. @@ -95,15 +202,42 @@ def _params(raw: Mapping[str, ParamValue]) -> QueryParamPairs: # -- response handling ------------------------------------------------- @staticmethod - def _decode(status_code: int, content: bytes, headers: Mapping[str, str]) -> Any: - """Parse a response body, raising the mapped error for non-2xx.""" - body: Any = None - if content: - try: - body = _json.loads(content) - except ValueError: - body = None - - if 200 <= status_code < 300: - return body - raise build_error(status_code, body, headers) + def _parse(content: bytes) -> Any: + """Parse a response body; ``None`` when it is empty or not JSON.""" + if not content: + return None + try: + return _json.loads(content) + except ValueError: + return None + + @staticmethod + def _response_info(status: int, headers: Mapping[str, str], body: Any) -> ResponseInfo: + request_id = headers.get("X-Request-Id") + if request_id is None and isinstance(body, dict): + from_body = body.get("request_id") + request_id = from_body if isinstance(from_body, str) else None + try: + credits: int | None = int(headers.get("X-Credits-Charged", "")) + except ValueError: + credits = None + return ResponseInfo( + status=status, headers=headers, request_id=request_id, credits_charged=credits, + ) + + @staticmethod + def _transport_error( + exc: httpx.TransportError, *, live: bool, sent: bool, timeout: float, + ) -> APIConnectionError: + if isinstance(exc, httpx.TimeoutException): + message = f"Request timed out after {timeout:g}s ({exc})." + if live and sent: + message += ( + f" {_LIVE_NOTE} Pass a higher timeout for this call (or on the client)" + " if it needs longer." + ) + error: APIConnectionError = APITimeoutError(message) + else: + error = APIConnectionError(f"{exc}. {_LIVE_NOTE}" if live and sent else str(exc)) + error.__cause__ = exc + return error diff --git a/src/firmendata/_pagination.py b/src/firmendata/_pagination.py new file mode 100644 index 0000000..2326eea --- /dev/null +++ b/src/firmendata/_pagination.py @@ -0,0 +1,43 @@ +"""Search pagination, shared by the sync and async ``iter_search``.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from .types import SearchResponse + +#: ``/companies/search`` serves at most this many results for one query. +SEARCH_RESULT_CAP = 10_000 +_SEARCH_PAGE_MAX = 50 + + +def search_plan( + max_results: int | None, filters: Mapping[str, Any], +) -> tuple[int, dict[str, Any], int | None]: + """The result cap, the first page's parameters and the page size to keep. + + Without an explicit ``limit``, a small ``max_results`` asks for just that + many hits; otherwise the server default (50) applies. + """ + cap = SEARCH_RESULT_CAP if max_results is None else min(max_results, SEARCH_RESULT_CAP) + params = dict(filters) + limit = params.get("limit") + if limit is None and max_results is not None and cap > 0: + limit = min(_SEARCH_PAGE_MAX, cap) + params["limit"] = limit + return cap, params, limit + + +def next_page_params( + page: SearchResponse, previous: Mapping[str, Any], limit: int | None, +) -> dict[str, Any] | None: + """Parameters for the next page — the cursor carries the filters — or ``None``.""" + pagination = page.get("pagination") or {} + cursor = pagination.get("next_cursor") + if not pagination.get("has_more") or not cursor or cursor == previous.get("cursor"): + return None + params: dict[str, Any] = {"cursor": cursor} + if limit is not None: + params["limit"] = limit + return params diff --git a/src/firmendata/_retry.py b/src/firmendata/_retry.py index c202b19..9fe13df 100644 --- a/src/firmendata/_retry.py +++ b/src/firmendata/_retry.py @@ -1,16 +1,25 @@ """Retry policy, kept as pure functions so sync and async share one brain. -Two rules do the real work: +Three rules do the real work: * **429 is always safe to retry, on any method.** The server rejects a rate-limited call *before* the handler runs — nothing happened, nothing was - billed. Its ``Retry-After`` is authoritative and is used verbatim. + billed. Its ``Retry-After`` is authoritative: the client waits that long, + and when it asks for longer than :data:`MAX_RETRY_DELAY_SECONDS` the + ``RateLimitError`` is raised at once instead of hanging the call. * **5xx and connection failures are retried only for idempotent methods.** A ``POST /v1/subscriptions`` that fails with a 502 may well have created the subscription before the proxy gave up; retrying it would create a second one. GET and DELETE can be repeated safely, POST cannot. +* **Live calls are never replayed once they may have reached the server.** + ``fetch_realtime=true`` requests and document listings and downloads are + billed when the server finishes them, whether or not the client is still + waiting. A timeout, a gateway timeout, or a connection that broke after the + request went out is therefore not retried — the retry could be billed a + second time. + Backoff is exponential with **full jitter** (``random(0, base * 2**n)``) rather than a fixed ramp: when many clients trip the same limit at once, an undithered backoff simply reconvenes them all at the same instant. @@ -24,8 +33,10 @@ IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"}) DEFAULT_MAX_RETRIES = 2 +#: The longest the client waits between two attempts, in seconds. +MAX_RETRY_DELAY_SECONDS = 20.0 _BASE_DELAY_SECONDS = 0.5 -_MAX_DELAY_SECONDS = 20.0 +_MAX_DELAY_SECONDS = MAX_RETRY_DELAY_SECONDS def should_retry( @@ -34,10 +45,17 @@ def should_retry( status_code: int | None, attempt: int, max_retries: int, + retry_after: float | None = None, + live: bool = False, + request_sent: bool = True, ) -> bool: """Whether to make attempt ``attempt + 1``. - ``status_code`` is ``None`` for a transport failure (no response). + ``status_code`` is ``None`` for a transport failure (no response); + ``request_sent`` is ``False`` only when that failure provably happened + before the request left the client. ``retry_after`` is a 429's + ``Retry-After`` in seconds. ``live`` marks a call billed when the server + finishes it (``fetch_realtime=true``, document listings and downloads). """ if attempt >= max_retries: return False @@ -45,11 +63,12 @@ def should_retry( idempotent = method.upper() in IDEMPOTENT_METHODS if status_code is None: # connection reset, timeout, DNS - return idempotent - if status_code == 429: # never executed — always replayable - return True - if status_code == 408: # server-side timeout, same reasoning as 5xx - return idempotent + # A live call that may have arrived could be billed twice. + return idempotent and not (live and request_sent) + if status_code == 429: # never executed — replayable, within reason + return retry_after is None or retry_after <= MAX_RETRY_DELAY_SECONDS + if status_code in (408, 504): # timeouts: the handler may still finish + return idempotent and not live if status_code >= 500: return idempotent return False diff --git a/src/firmendata/async_client.py b/src/firmendata/async_client.py index d32acfb..fb293ff 100644 --- a/src/firmendata/async_client.py +++ b/src/firmendata/async_client.py @@ -9,15 +9,28 @@ from __future__ import annotations import asyncio +import inspect +from collections.abc import AsyncIterator from typing import Any, Literal, Unpack import httpx -from ._base import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, _BaseClient, _segment +from ._base import ( + DEFAULT_BASE_URL, + DEFAULT_TIMEOUT, + AppInfo, + ResponseHook, + _BaseClient, + _financial_params, + _request_sent, + _segment, +) from ._feeds import AsyncFeedIterator +from ._pagination import next_page_params, search_plan from ._retry import DEFAULT_MAX_RETRIES, backoff_seconds, should_retry -from .errors import APIConnectionError, APITimeoutError, RateLimitError +from .errors import RateLimitError, build_error from .params import ( + CompanyListFilters, FeedInsolvenciesFilters, FeedNewCompaniesFilters, SearchFilters, @@ -30,15 +43,20 @@ CompanyDocumentList, CompanyFinancials, CompanyHistory, + CompanyListDetailResponse, + CompanyListsResponse, FeedResponse, + SearchHit, SearchResponse, ShareholdersReport, Subscription, SubscriptionCreated, + SubscriptionCreateRequest, SubscriptionEvent, SubscriptionEventList, SubscriptionEventResendResponse, SubscriptionList, + SubscriptionTestRequest, SubscriptionTestResponse, UboReport, ) @@ -53,6 +71,8 @@ class AsyncFirmenData(_BaseClient): ... hits = await fd.autocomplete("siemens") The API key is optional; without one only :meth:`autocomplete` works. + ``on_response`` may be a plain function or a coroutine function; the + client awaits the latter. """ def __init__( @@ -63,9 +83,12 @@ def __init__( timeout: float = DEFAULT_TIMEOUT, max_retries: int = DEFAULT_MAX_RETRIES, http_client: httpx.AsyncClient | None = None, + app_info: AppInfo | None = None, + on_response: ResponseHook | None = None, ) -> None: super().__init__( api_key, base_url=base_url, timeout=timeout, max_retries=max_retries, + app_info=app_info, on_response=on_response, ) self._owns_client = http_client is None self._http = http_client or httpx.AsyncClient(timeout=timeout) @@ -91,44 +114,49 @@ async def _request( *, params: dict[str, Any] | None = None, json: dict[str, Any] | None = None, + live: bool = False, + timeout: float | None = None, ) -> Any: url = self._url(path) query = self._params(params or {}) + live = live or (params or {}).get("fetch_realtime") is True + effective_timeout = self._timeout_for(live, timeout) attempt = 0 while True: status: int | None = None retry_after: float | None = None + sent = True try: response = await self._http.request( method, url, params=httpx.QueryParams(query) if query else None, json=json, headers=self._headers(), - timeout=self.timeout, + timeout=effective_timeout, + ) + except httpx.TransportError as exc: + sent = _request_sent(exc) + error: Exception = self._transport_error( + exc, live=live, sent=sent, timeout=effective_timeout, ) + else: status = response.status_code + body = self._parse(response.content) + if self.on_response is not None: + result = self.on_response(self._response_info(status, response.headers, body)) + if inspect.isawaitable(result): + await result if 200 <= status < 300: - return self._decode(status, response.content, response.headers) - - try: - self._decode(status, response.content, response.headers) - except RateLimitError as exc: - retry_after = exc.retry_after - error: Exception = exc - except Exception as exc: # noqa: BLE001 - re-raised below - error = exc - else: # pragma: no cover - _decode always raises here - raise AssertionError("non-2xx did not raise") - - except (httpx.TimeoutException, httpx.TransportError) as exc: - error = APITimeoutError(str(exc)) if isinstance( - exc, httpx.TimeoutException - ) else APIConnectionError(str(exc)) + return body + error = build_error(status, body, response.headers) + if isinstance(error, RateLimitError): + retry_after = error.retry_after if not should_retry( method=method, status_code=status, attempt=attempt, max_retries=self.max_retries, + retry_after=retry_after, live=live, request_sent=sent, ): raise error @@ -139,7 +167,7 @@ async def _request( async def autocomplete( self, q: str, *, limit: int = 10, fetch_realtime: bool = False, - country: Literal["DE", "CH", "AT"] | None = None, + country: Literal["DE", "CH", "AT"] | None = None, timeout: float | None = None, ) -> AutocompleteResponse: """Free, keyless company-name suggestions. See the sync client.""" return await self._request( @@ -147,55 +175,92 @@ async def autocomplete( params={ "q": q, "limit": limit, "fetch_realtime": fetch_realtime, "country": country, }, + timeout=timeout, ) - async def search(self, **filters: Unpack[SearchFilters]) -> SearchResponse: + async def search( + self, *, timeout: float | None = None, **filters: Unpack[SearchFilters], + ) -> SearchResponse: """Advanced register search.""" - return await self._request("GET", "/v1/companies/search", params=dict(filters)) + return await self._request( + "GET", "/v1/companies/search", params=dict(filters), timeout=timeout, + ) + + async def iter_search( + self, *, max_results: int | None = None, **filters: Unpack[SearchFilters], + ) -> AsyncIterator[SearchHit]: + """Use ``async for`` to page through every hit. See the sync client.""" + cap, params, limit = search_plan(max_results, filters) + if cap <= 0: + return + yielded = 0 + while True: + page: SearchResponse = await self._request( + "GET", "/v1/companies/search", params=params, + ) + for hit in page.get("data") or []: + yield hit + yielded += 1 + if yielded >= cap: + return + next_params = next_page_params(page, params, limit) + if next_params is None: + return + params = next_params async def get_company( - self, eu_id: str, *, fetch_realtime: bool = False, + self, eu_id: str, *, fetch_realtime: bool = False, timeout: float | None = None, ) -> CompanyDetail: return await self._request( - "GET", f"/v1/companies/{_segment(eu_id)}", params={"fetch_realtime": fetch_realtime}, + "GET", f"/v1/companies/{_segment(eu_id)}", + params={"fetch_realtime": fetch_realtime}, timeout=timeout, ) async def get_financials( - self, eu_id: str, *, include_line_items: bool = False, years: int | None = None, + self, + eu_id: str, + *, + details: bool | None = None, + include_line_items: bool | None = None, + years: int | None = None, ) -> CompanyFinancials: + """Multi-year financial statements. See the sync client.""" return await self._request( "GET", f"/v1/companies/{_segment(eu_id)}/financials", - params={ - "include": ["line_items"] if include_line_items else None, - "years": years, - }, + params=_financial_params(details, include_line_items, years), ) async def get_shareholders( - self, eu_id: str, *, fetch_realtime: bool = False, + self, eu_id: str, *, fetch_realtime: bool = False, timeout: float | None = None, ) -> ShareholdersReport: return await self._request( "GET", f"/v1/companies/{_segment(eu_id)}/shareholders", - params={"fetch_realtime": fetch_realtime}, + params={"fetch_realtime": fetch_realtime}, timeout=timeout, ) - async def get_ubo(self, eu_id: str, *, fetch_realtime: bool = False) -> UboReport: + async def get_ubo( + self, eu_id: str, *, fetch_realtime: bool = False, timeout: float | None = None, + ) -> UboReport: return await self._request( "GET", f"/v1/companies/{_segment(eu_id)}/ubo", - params={"fetch_realtime": fetch_realtime}, + params={"fetch_realtime": fetch_realtime}, timeout=timeout, ) async def get_history( - self, eu_id: str, *, fetch_realtime: bool = False, + self, eu_id: str, *, fetch_realtime: bool = False, timeout: float | None = None, ) -> CompanyHistory: return await self._request( "GET", f"/v1/companies/{_segment(eu_id)}/history", - params={"fetch_realtime": fetch_realtime}, + params={"fetch_realtime": fetch_realtime}, timeout=timeout, ) - async def list_documents(self, eu_id: str) -> CompanyDocumentList: + async def list_documents( + self, eu_id: str, *, timeout: float | None = None, + ) -> CompanyDocumentList: """Check the registry live and list documents. See the sync client.""" - return await self._request("GET", f"/v1/companies/{_segment(eu_id)}/documents") + return await self._request( + "GET", f"/v1/companies/{_segment(eu_id)}/documents", live=True, timeout=timeout, + ) async def download_document( self, @@ -205,6 +270,7 @@ async def download_document( file_id: str | None = None, document_id: str | None = None, fetch_realtime: bool = False, + timeout: float | None = None, ) -> CompanyDocumentDownload: return await self._request( "GET", f"/v1/companies/{_segment(eu_id)}/documents/download", @@ -214,6 +280,8 @@ async def download_document( "document_id": document_id, "fetch_realtime": fetch_realtime, }, + live=True, + timeout=timeout, ) # -- event feeds (Beta) ------------------------------------------------ @@ -221,7 +289,7 @@ async def download_document( async def feed_new_companies( self, **filters: Unpack[FeedNewCompaniesFilters], ) -> FeedResponse: - """One page of new-company events (Beta). See the sync client.""" + """One page of newly registered companies (Beta). See the sync client.""" return await self._request("GET", "/v1/feeds/new-companies", params=dict(filters)) async def feed_insolvencies( @@ -245,6 +313,20 @@ def iter_feed_insolvencies( """Async iteration over insolvency notices. See :meth:`iter_feed_new_companies`.""" return AsyncFeedIterator(lambda params: self.feed_insolvencies(**params), dict(filters)) + # -- lists ------------------------------------------------------------- + + async def get_lists(self) -> CompanyListsResponse: + """Your company lists, newest first. Free. See the sync client.""" + return await self._request("GET", "/v1/lists") + + async def get_list( + self, list_id: int, **params: Unpack[CompanyListFilters], + ) -> CompanyListDetailResponse: + """One page of companies from one of your lists. Free. See the sync client.""" + return await self._request( + "GET", f"/v1/lists/{_segment(str(list_id))}", params=dict(params), + ) + # -- subscriptions ----------------------------------------------------- async def list_subscriptions( @@ -252,9 +334,11 @@ async def list_subscriptions( ) -> SubscriptionList: return await self._request("GET", "/v1/subscriptions", params=dict(filters)) - async def create_subscription(self, **body: Any) -> SubscriptionCreated: + async def create_subscription( + self, **body: Unpack[SubscriptionCreateRequest], + ) -> SubscriptionCreated: """Not retried on 5xx — a replay could create a duplicate.""" - return await self._request("POST", "/v1/subscriptions", json=body) + return await self._request("POST", "/v1/subscriptions", json=dict(body)) async def get_subscription(self, subscription_id: str) -> Subscription: return await self._request("GET", f"/v1/subscriptions/{_segment(subscription_id)}") @@ -279,5 +363,7 @@ async def resend_event(self, event_id: str) -> SubscriptionEventResendResponse: "POST", f"/v1/subscriptions/events/{_segment(event_id)}/resend", ) - async def test_delivery(self, **body: Any) -> SubscriptionTestResponse: - return await self._request("POST", "/v1/subscriptions/test", json=body) + async def test_delivery( + self, **body: Unpack[SubscriptionTestRequest], + ) -> SubscriptionTestResponse: + return await self._request("POST", "/v1/subscriptions/test", json=dict(body)) diff --git a/src/firmendata/client.py b/src/firmendata/client.py index a6d756b..c088f53 100644 --- a/src/firmendata/client.py +++ b/src/firmendata/client.py @@ -3,15 +3,27 @@ from __future__ import annotations import time +from collections.abc import Iterator from typing import Any, Literal, Unpack import httpx -from ._base import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, _BaseClient, _segment +from ._base import ( + DEFAULT_BASE_URL, + DEFAULT_TIMEOUT, + AppInfo, + ResponseHook, + _BaseClient, + _financial_params, + _request_sent, + _segment, +) from ._feeds import FeedIterator +from ._pagination import next_page_params, search_plan from ._retry import DEFAULT_MAX_RETRIES, backoff_seconds, should_retry -from .errors import APIConnectionError, APITimeoutError, RateLimitError +from .errors import RateLimitError, build_error from .params import ( + CompanyListFilters, FeedInsolvenciesFilters, FeedNewCompaniesFilters, SearchFilters, @@ -24,15 +36,20 @@ CompanyDocumentList, CompanyFinancials, CompanyHistory, + CompanyListDetailResponse, + CompanyListsResponse, FeedResponse, + SearchHit, SearchResponse, ShareholdersReport, Subscription, SubscriptionCreated, + SubscriptionCreateRequest, SubscriptionEvent, SubscriptionEventList, SubscriptionEventResendResponse, SubscriptionList, + SubscriptionTestRequest, SubscriptionTestResponse, UboReport, ) @@ -57,8 +74,16 @@ class FirmenData(_BaseClient): company = fd.get_company(eu_id) Retries are automatic and conservative: 429s always (the server rejects - them before doing any work), 5xx and connection failures only for - idempotent methods. See :mod:`firmendata._retry`. + them before doing any work) unless ``Retry-After`` asks for more than 20 + seconds, 5xx and connection failures only for idempotent methods, and live + calls (``fetch_realtime=True``, document listings and downloads) never once + they may have reached the server. See :mod:`firmendata._retry`. + + ``timeout`` is per attempt; live calls wait at least + :data:`~firmendata.REALTIME_TIMEOUT` (150 s) unless the call passes its + own ``timeout``. ``app_info={"name": ..., "version": ..., "url": ...}`` + identifies your integration in the ``User-Agent``. ``on_response`` is + called with a :class:`~firmendata.ResponseInfo` for every HTTP response. """ def __init__( @@ -69,9 +94,12 @@ def __init__( timeout: float = DEFAULT_TIMEOUT, max_retries: int = DEFAULT_MAX_RETRIES, http_client: httpx.Client | None = None, + app_info: AppInfo | None = None, + on_response: ResponseHook | None = None, ) -> None: super().__init__( api_key, base_url=base_url, timeout=timeout, max_retries=max_retries, + app_info=app_info, on_response=on_response, ) self._owns_client = http_client is None self._http = http_client or httpx.Client(timeout=timeout) @@ -97,45 +125,48 @@ def _request( *, params: dict[str, Any] | None = None, json: dict[str, Any] | None = None, + live: bool = False, + timeout: float | None = None, ) -> Any: url = self._url(path) query = self._params(params or {}) + live = live or (params or {}).get("fetch_realtime") is True + effective_timeout = self._timeout_for(live, timeout) attempt = 0 while True: status: int | None = None retry_after: float | None = None + sent = True try: response = self._http.request( method, url, params=httpx.QueryParams(query) if query else None, json=json, headers=self._headers(), - timeout=self.timeout, + timeout=effective_timeout, + ) + except httpx.TransportError as exc: + sent = _request_sent(exc) + error: Exception = self._transport_error( + exc, live=live, sent=sent, timeout=effective_timeout, ) + else: status = response.status_code + body = self._parse(response.content) + # Outside the try, so an exception from the hook reaches the caller as-is. + if self.on_response is not None: + self.on_response(self._response_info(status, response.headers, body)) if 200 <= status < 300: - return self._decode(status, response.content, response.headers) - - # Build the error now so a 429's Retry-After informs the wait. - try: - self._decode(status, response.content, response.headers) - except RateLimitError as exc: - retry_after = exc.retry_after - error: Exception = exc - except Exception as exc: # noqa: BLE001 - re-raised below - error = exc - else: # pragma: no cover - _decode always raises here - raise AssertionError("non-2xx did not raise") - - except (httpx.TimeoutException, httpx.TransportError) as exc: - error = APITimeoutError(str(exc)) if isinstance( - exc, httpx.TimeoutException - ) else APIConnectionError(str(exc)) + return body + error = build_error(status, body, response.headers) + if isinstance(error, RateLimitError): + retry_after = error.retry_after if not should_retry( method=method, status_code=status, attempt=attempt, max_retries=self.max_retries, + retry_after=retry_after, live=live, request_sent=sent, ): raise error @@ -151,6 +182,7 @@ def autocomplete( limit: int = 10, fetch_realtime: bool = False, country: Literal["DE", "CH", "AT"] | None = None, + timeout: float | None = None, ) -> AutocompleteResponse: """Company-name suggestions for a search box. @@ -161,7 +193,8 @@ def autocomplete( Keyless calls are rate limited by address; supplying a key raises that substantially. The exact keyless limits are not published and may be tightened without notice — honour ``Retry-After`` on a 429 - (this client does). + (this client does, up to 20 seconds; a longer wait raises + :class:`~firmendata.errors.RateLimitError` at once). ``fetch_realtime`` searches the German registers live so a company founded days ago is findable immediately. It **requires an API key** @@ -173,10 +206,13 @@ def autocomplete( params={ "q": q, "limit": limit, "fetch_realtime": fetch_realtime, "country": country, }, + timeout=timeout, ) - def search(self, **filters: Unpack[SearchFilters]) -> SearchResponse: - """Advanced search over the German and Swiss commercial registers. + def search( + self, *, timeout: float | None = None, **filters: Unpack[SearchFilters], + ) -> SearchResponse: + """Advanced search over the German, Swiss and Austrian commercial registers. Every filter is optional. Different filters combine with AND; ``city``, ``bundesland`` and ``canton`` form one OR location filter. @@ -187,59 +223,113 @@ def search(self, **filters: Unpack[SearchFilters]) -> SearchResponse: Paginate by passing ``pagination.next_cursor`` back as ``cursor``, unchanged — cursors are signed, and an edited one is rejected. Unknown filter names and inverted ranges raise ``ValidationError``. + :meth:`iter_search` pages for you. """ - return self._request("GET", "/v1/companies/search", params=dict(filters)) + return self._request( + "GET", "/v1/companies/search", params=dict(filters), timeout=timeout, + ) + + def iter_search( + self, *, max_results: int | None = None, **filters: Unpack[SearchFilters], + ) -> Iterator[SearchHit]: + """Yield every hit of a search, page by page, following ``next_cursor``. - def get_company(self, eu_id: str, *, fetch_realtime: bool = False) -> CompanyDetail: + After the first page only the cursor (and ``limit``) is sent — the + cursor carries the filters. Stops when ``has_more`` is false, after + ``max_results`` hits, or at the API's 10,000-result cap. Each page (up + to 50 hits) is one call, billed like :meth:`search`; stop iterating + to stop paying for pages you do not need. + """ + cap, params, limit = search_plan(max_results, filters) + if cap <= 0: + return + yielded = 0 + while True: + page: SearchResponse = self._request("GET", "/v1/companies/search", params=params) + for hit in page.get("data") or []: + yield hit + yielded += 1 + if yielded >= cap: + return + next_params = next_page_params(page, params, limit) + if next_params is None: + return + params = next_params + + def get_company( + self, eu_id: str, *, fetch_realtime: bool = False, timeout: float | None = None, + ) -> CompanyDetail: """Full profile for one company.""" return self._request( "GET", f"/v1/companies/{_segment(eu_id)}", - params={"fetch_realtime": fetch_realtime}, + params={"fetch_realtime": fetch_realtime}, timeout=timeout, ) def get_financials( - self, eu_id: str, *, include_line_items: bool = False, years: int | None = None, + self, + eu_id: str, + *, + details: bool | None = None, + include_line_items: bool | None = None, + years: int | None = None, ) -> CompanyFinancials: """Multi-year financial statements. - Lean by default: the structured P&L / balance-sheet rows come only with - ``include_line_items=True``, and ``years`` keeps the N most recent - fiscal years in every per-year array. + By default the response carries every filed P&L, assets and + liabilities-and-equity row (``history.profit_and_loss``, + ``history.assets``, ``history.liabilities_and_equity``) — often + 100 KB+ for groups. Pass ``details=False`` for the lean response: + those lists stay empty, and each year keeps its top-level + ``balance_sheet`` and ``income_statement`` positions in + ``history.metrics``. Both cost the same. ``years`` keeps the N most + recent fiscal years in every per-year array. + + ``include_line_items`` is deprecated (it emits a + :class:`DeprecationWarning`) and is sent as ``details``; an explicit + ``details`` wins. """ return self._request( "GET", f"/v1/companies/{_segment(eu_id)}/financials", - params={ - "include": ["line_items"] if include_line_items else None, - "years": years, - }, + params=_financial_params(details, include_line_items, years), ) def get_shareholders( - self, eu_id: str, *, fetch_realtime: bool = False, + self, eu_id: str, *, fetch_realtime: bool = False, timeout: float | None = None, ) -> ShareholdersReport: """Cap table from the most recent Gesellschafterliste (GmbH/UG).""" return self._request( "GET", f"/v1/companies/{_segment(eu_id)}/shareholders", - params={"fetch_realtime": fetch_realtime}, + params={"fetch_realtime": fetch_realtime}, timeout=timeout, ) - def get_ubo(self, eu_id: str, *, fetch_realtime: bool = False) -> UboReport: + def get_ubo( + self, eu_id: str, *, fetch_realtime: bool = False, timeout: float | None = None, + ) -> UboReport: """Ultimate beneficial owners, resolved through ownership chains.""" return self._request( "GET", f"/v1/companies/{_segment(eu_id)}/ubo", - params={"fetch_realtime": fetch_realtime}, + params={"fetch_realtime": fetch_realtime}, timeout=timeout, ) - def get_history(self, eu_id: str, *, fetch_realtime: bool = False) -> CompanyHistory: + def get_history( + self, eu_id: str, *, fetch_realtime: bool = False, timeout: float | None = None, + ) -> CompanyHistory: """Chronological register history.""" return self._request( "GET", f"/v1/companies/{_segment(eu_id)}/history", - params={"fetch_realtime": fetch_realtime}, + params={"fetch_realtime": fetch_realtime}, timeout=timeout, ) - def list_documents(self, eu_id: str) -> CompanyDocumentList: - """Check the registry live and list documents, including older DK versions.""" - return self._request("GET", f"/v1/companies/{_segment(eu_id)}/documents") + def list_documents( + self, eu_id: str, *, timeout: float | None = None, + ) -> CompanyDocumentList: + """Check the registry live and list documents, including older versions. + + A live, billed call: never retried once it may have reached the server. + """ + return self._request( + "GET", f"/v1/companies/{_segment(eu_id)}/documents", live=True, timeout=timeout, + ) def download_document( self, @@ -249,12 +339,16 @@ def download_document( file_id: str | None = None, document_id: str | None = None, fetch_realtime: bool = False, + timeout: float | None = None, ) -> CompanyDocumentDownload: """Presigned download URL for a register document. - Pass ``document_id`` from :meth:`list_documents` for a specific DK + Pass ``document_id`` from :meth:`list_documents` for a specific version, with its matching ``file_type``. It cannot be combined with ``file_id`` or ``fetch_realtime=True``. Omit it for the latest version. + + A live, billed call: never retried once it may have reached the + server, with a timeout of at least 150 s unless ``timeout`` is set. """ return self._request( "GET", f"/v1/companies/{_segment(eu_id)}/documents/download", @@ -264,20 +358,22 @@ def download_document( "document_id": document_id, "fetch_realtime": fetch_realtime, }, + live=True, + timeout=timeout, ) # -- event feeds (Beta) ------------------------------------------------ def feed_new_companies(self, **filters: Unpack[FeedNewCompaniesFilters]) -> FeedResponse: - """One page of new-company events (Beta); requires an API key. + """One page of newly registered companies (Beta); requires an API key. ``since`` is an ISO datetime for the first call only, at most seven days back (defaults to the last day). ``limit`` is 1–100, default 50. Persist ``next_cursor`` even after an empty page and pass it back unchanged. Page immediately while ``has_more`` is true, otherwise poll later. Deduplicate on event ``id``. A page with events costs - one credit; empty pages are free. Discovery positions older than - seven days expire; restart with a recent ``since``. + one credit; empty pages are free. Positions older than seven days + expire; restart with a recent ``since``. """ return self._request("GET", "/v1/feeds/new-companies", params=dict(filters)) @@ -307,19 +403,43 @@ def iter_feed_insolvencies( """Yield available insolvency notices. See :meth:`iter_feed_new_companies`.""" return FeedIterator(lambda params: self.feed_insolvencies(**params), dict(filters)) + # -- lists ------------------------------------------------------------- + + def get_lists(self) -> CompanyListsResponse: + """Your company lists, newest first. Free. + + Each entry carries the list's ``list_id``, ``name``, ``company_count`` + and alert setting. Lists are created and edited in the web app. + """ + return self._request("GET", "/v1/lists") + + def get_list( + self, list_id: int, **params: Unpack[CompanyListFilters], + ) -> CompanyListDetailResponse: + """One page of companies from one of your lists. Free. + + Rows have the same shape as search hits; ``sort`` orders the whole + list before the page is cut. Paginate with ``pagination.next_cursor`` + as ``cursor``. An unknown list id, or one that belongs to someone + else, raises :class:`~firmendata.errors.NotFoundError`. + """ + return self._request("GET", f"/v1/lists/{_segment(str(list_id))}", params=dict(params)) + # -- subscriptions ----------------------------------------------------- def list_subscriptions(self, **filters: Unpack[SubscriptionFilters]) -> SubscriptionList: """List change subscriptions.""" return self._request("GET", "/v1/subscriptions", params=dict(filters)) - def create_subscription(self, **body: Any) -> SubscriptionCreated: + def create_subscription( + self, **body: Unpack[SubscriptionCreateRequest], + ) -> SubscriptionCreated: """Create a change subscription. Not retried on 5xx: a create that times out may already have been applied, and a blind replay would produce a duplicate. """ - return self._request("POST", "/v1/subscriptions", json=body) + return self._request("POST", "/v1/subscriptions", json=dict(body)) def get_subscription(self, subscription_id: str) -> Subscription: return self._request("GET", f"/v1/subscriptions/{_segment(subscription_id)}") @@ -341,8 +461,11 @@ def get_event(self, event_id: str) -> SubscriptionEvent: return self._request("GET", f"/v1/subscriptions/events/{_segment(event_id)}") def resend_event(self, event_id: str) -> SubscriptionEventResendResponse: + """Deliver a past event again; the result records the new delivery attempt.""" return self._request("POST", f"/v1/subscriptions/events/{_segment(event_id)}/resend") - def test_delivery(self, **body: Any) -> SubscriptionTestResponse: - """Send a synthetic event to a webhook URL to verify delivery.""" - return self._request("POST", "/v1/subscriptions/test", json=body) + def test_delivery( + self, **body: Unpack[SubscriptionTestRequest], + ) -> SubscriptionTestResponse: + """Send a synthetic event to a webhook URL or email address to verify delivery.""" + return self._request("POST", "/v1/subscriptions/test", json=dict(body)) diff --git a/src/firmendata/errors.py b/src/firmendata/errors.py index cb5adb4..cc33730 100644 --- a/src/firmendata/errors.py +++ b/src/firmendata/errors.py @@ -7,6 +7,7 @@ "title": "Insufficient credits.", "status": 402, "detail": "Your credit balance is too low for this request.", + "message": "Your credit balance is too low for this request.", "instance": "/v1/companies/DE.../ubo", "request_id": "8e510d86-..." } @@ -113,9 +114,11 @@ class ValidationError(APIError): class RateLimitError(APIError): """Rate limit exceeded. - ``retry_after`` is the server's ``Retry-After`` in seconds when present. - The client retries these automatically unless ``max_retries=0``; seeing - one means the retry budget was exhausted. + ``retry_after`` is the server's ``Retry-After`` in seconds when present — + wait that long. The client retries these automatically unless + ``max_retries=0``; seeing one means the retry budget was exhausted, or the + server asked for a longer wait than the client blocks for (20 seconds) — + the keyless daily limit, for example — and it was raised at once. """ def __init__(self, *args: Any, retry_after: float | None = None, **kwargs: Any) -> None: @@ -180,7 +183,8 @@ def build_error( detail = problem.get("detail") title = problem.get("title") - message = detail or title or f"HTTP {status_code}" + # ``message`` repeats ``detail`` (or ``title``) for clients that read that name. + message = detail or problem.get("message") or title or f"HTTP {status_code}" kwargs: dict[str, Any] = dict( status_code=status_code, diff --git a/src/firmendata/params.py b/src/firmendata/params.py index 924fb88..d90b478 100644 --- a/src/firmendata/params.py +++ b/src/firmendata/params.py @@ -4,7 +4,7 @@ from typing import Any, Literal, NotRequired, TypedDict -__all__ = ['SearchFilters', 'SubscriptionFilters', 'FeedNewCompaniesFilters', 'FeedInsolvenciesFilters'] +__all__ = ['SearchFilters', 'SubscriptionFilters', 'FeedNewCompaniesFilters', 'FeedInsolvenciesFilters', 'CompanyListFilters'] class SearchFilters(TypedDict, total=False): @@ -12,7 +12,7 @@ class SearchFilters(TypedDict, total=False): # Unified keyword, tried as an eu_id, then a company-name prefix, then a person name —... q: NotRequired[str] - # Company name prefix, matched against the folded legal name with quotes and invisible... + # Company name prefix, ignoring case, accents, quotes and invisible characters — `Mülle... company_name: NotRequired[str] # Exact eu_id lookup (no prefix expansion). eu_id: NotRequired[str] @@ -72,7 +72,7 @@ class SearchFilters(TypedDict, total=False): avg_birth_year_min: NotRequired[int] # Maximum average birth year across the company's associated persons. avg_birth_year_max: NotRequired[int] - # Company lifecycle status, resolved from the merged Handelsregister (SI + CD) and Inso... + # Company lifecycle status according to the commercial register and insolvency notices,... legal_status: NotRequired[list[Literal['active', 'in_liquidation', 'insolvent', 'dissolved', 'deleted']]] # Statutory size class under § 267 / § 267a HGB. Multi-valued. - `kleinst` — Kleinstkap... company_size: NotRequired[list[Literal['kleinst', 'klein', 'mittelgross']]] @@ -162,3 +162,15 @@ class FeedInsolvenciesFilters(TypedDict, total=False): revenue_min: NotRequired[float] # Exclusive upper bound on the company's latest known revenue in euros. revenue_max: NotRequired[float] + +class CompanyListFilters(TypedDict, total=False): + """Companies on a list.""" + + # Companies per page. + limit: NotRequired[int] + # Opaque pagination cursor. Pass the `pagination.next_cursor` from a previous response... + cursor: NotRequired[str] + # Order the list by a company attribute instead of by when companies were added. Same v... + sort: NotRequired[Literal['name', 'revenue', 'profit', 'total_assets', 'employee_count']] + # Sort direction; applies only with `sort`. + sort_direction: NotRequired[Literal['asc', 'desc']] diff --git a/src/firmendata/types.py b/src/firmendata/types.py index d1ffd6b..b51ea94 100644 --- a/src/firmendata/types.py +++ b/src/firmendata/types.py @@ -520,6 +520,7 @@ class Problem(TypedDict): detail: NotRequired[str] errors: NotRequired[list[Error]] instance: NotRequired[str] + message: str request_id: str status: int title: str diff --git a/tests/test_client.py b/tests/test_client.py index 08ac865..97953e5 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -18,6 +18,7 @@ NotFoundError, RateLimitError, ValidationError, + __version__, ) from firmendata._retry import backoff_seconds, should_retry from firmendata.errors import APIConnectionError, ServerError @@ -188,7 +189,7 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={}) client_with(handler).autocomplete("sap") - assert seen["ua"].startswith("firmendata-python/") + assert seen["ua"] == f"firmendata-python/{__version__}" def test_path_ids_are_percent_encoded(self): """A hostile id must not be able to rewrite the request target. @@ -251,18 +252,20 @@ def handler(request): client_with(handler, max_retries=0).autocomplete("ab") assert exc.value.errors[0]["param"] == "q" - def test_financial_line_items_are_opt_in(self): - seen = [] - + def test_problem_message_is_passed_through(self): def handler(request): - seen.append(request.url.params) - return httpx.Response(200, json={}) - - client = client_with(handler, max_retries=0) - client.get_financials("DE1") - client.get_financials("DE1", include_line_items=True, years=3) - assert not seen[0] - assert seen[1].get_list("include") == ["line_items"] and seen[1]["years"] == "3" + return httpx.Response(402, json={ + "type": "https://api.firmendata.com/problems/insufficient-credits", + "title": "Insufficient credits.", + "status": 402, + "message": "Not enough credits for this call", + "request_id": "req-1", + }) + + with pytest.raises(InsufficientCreditsError) as exc: + client_with(handler, max_retries=0).get_ubo("DE1") + assert exc.value.args[0] == "Not enough credits for this call" + assert exc.value.request_id == "req-1" def test_rate_limit_carries_retry_after(self): def handler(request): diff --git a/tests/test_requests.py b/tests/test_requests.py new file mode 100644 index 0000000..65d107e --- /dev/null +++ b/tests/test_requests.py @@ -0,0 +1,545 @@ +"""Request identification, live-call safety, rate-limit waits, response +metadata and the pagination helpers — on both clients. + +Mirrors the Node SDK's ``test/requests.test.ts``. No network, no credentials. +""" + +from __future__ import annotations + +import time +import warnings +from types import SimpleNamespace + +import httpx +import pytest + +import firmendata.async_client +import firmendata.client +from firmendata import ( + APIConnectionError, + APITimeoutError, + AsyncFirmenData, + FirmenData, + NotFoundError, + RateLimitError, + ResponseInfo, + ServerError, + __version__, +) +from firmendata._retry import should_retry + + +@pytest.fixture(params=[FirmenData, AsyncFirmenData], ids=["sync", "async"]) +async def make_client(request, monkeypatch): + """Build either client over a mock transport, with backoff sleeps skipped.""" + monkeypatch.setattr(firmendata.client, "time", SimpleNamespace(sleep=lambda _s: None)) + + async def no_sleep(_seconds): + return None + + monkeypatch.setattr(firmendata.async_client, "asyncio", SimpleNamespace(sleep=no_sleep)) + transports = [] + + def make(handler, **kwargs): + is_async = request.param is AsyncFirmenData + transport = (httpx.AsyncClient if is_async else httpx.Client)( + transport=httpx.MockTransport(handler), + ) + transports.append(transport) + kwargs.setdefault("max_retries", 0) + return request.param(http_client=transport, **kwargs) + + yield make + for transport in transports: + if isinstance(transport, httpx.AsyncClient): + await transport.aclose() + else: + transport.close() + + +async def call(client, method, *args, **kwargs): + result = getattr(client, method)(*args, **kwargs) + return await result if isinstance(client, AsyncFirmenData) else result + + +async def collect(client, method, **kwargs): + items = getattr(client, method)(**kwargs) + if isinstance(client, AsyncFirmenData): + return [item async for item in items] + return list(items) + + +def problem(slug, status): + return { + "type": f"https://api.firmendata.com/problems/{slug}", + "title": slug, + "status": status, + "detail": "boom", + "request_id": "req-body", + } + + +# --------------------------------------------------------------------------- +# Client identification +# --------------------------------------------------------------------------- + +async def test_client_headers(make_client): + seen = {} + + def handler(request): + seen.update(request.headers) + return httpx.Response(200, json={"data": []}) + + await call(make_client(handler), "autocomplete", "sap") + assert seen["x-firmendata-client"] == f"python-sdk/{__version__}" + assert seen["user-agent"] == f"firmendata-python/{__version__}" + + +async def test_app_info_is_appended_to_the_user_agent(make_client): + seen = {} + + def handler(request): + seen["ua"] = request.headers["user-agent"] + return httpx.Response(200, json={"data": []}) + + client = make_client( + handler, app_info={"name": "acme-crm", "version": "2.1.0", "url": "https://acme.example"}, + ) + await call(client, "autocomplete", "sap") + assert seen["ua"] == f"firmendata-python/{__version__} acme-crm/2.1.0 (https://acme.example)" + + +def test_app_info_name_only_and_truncated(): + fd = FirmenData(app_info={"name": "x" * 200}) + assert fd._headers()["User-Agent"] == f"firmendata-python/{__version__} {'x' * 64}" + + +@pytest.mark.parametrize("app_info", [ + {"name": "evil\r\nX-Injected: 1"}, + {"name": "ok", "version": "1\n2"}, + {"name": "ok", "url": "https://a.example\r"}, + {"name": " "}, +]) +def test_unusable_app_info_is_rejected(app_info): + with pytest.raises(ValueError): + FirmenData(app_info=app_info) + + +# --------------------------------------------------------------------------- +# Lists +# --------------------------------------------------------------------------- + +async def test_get_lists(make_client): + body = {"object": "list", "data": [{"list_id": 42, "name": "Targets", "company_count": 3}]} + + def handler(request): + assert request.method == "GET" + assert request.url.path == "/v1/lists" + assert not request.url.params + assert request.headers["authorization"] == "Bearer k" + return httpx.Response(200, json=body) + + assert await call(make_client(handler, api_key="k"), "get_lists") == body + + +async def test_get_list(make_client): + body = {"object": "list", "list_id": 42, "name": "Targets", "data": []} + + def handler(request): + assert request.url.path == "/v1/lists/42" + assert dict(request.url.params) == { + "limit": "200", "cursor": "c+/=", "sort": "revenue", "sort_direction": "asc", + } + return httpx.Response(200, json=body) + + result = await call( + make_client(handler, api_key="k"), "get_list", 42, + limit=200, cursor="c+/=", sort="revenue", sort_direction="asc", + ) + assert result == body + + +async def test_unknown_list_is_not_found(make_client): + def handler(request): + return httpx.Response(404, json=problem("not-found", 404)) + + with pytest.raises(NotFoundError): + await call(make_client(handler, api_key="k"), "get_list", 7) + + +# --------------------------------------------------------------------------- +# Financials detail level +# --------------------------------------------------------------------------- + +async def test_financials_details(make_client): + seen = [] + + def handler(request): + seen.append(dict(request.url.params)) + return httpx.Response(200, json={}) + + client = make_client(handler) + with warnings.catch_warnings(): + warnings.simplefilter("error") # the new spelling never warns + await call(client, "get_financials", "DE1") + await call(client, "get_financials", "DE1", details=False, years=3) + assert seen == [{}, {"details": "false", "years": "3"}] + + +async def test_include_line_items_is_deprecated_and_maps_to_details(make_client): + seen = [] + + def handler(request): + seen.append(dict(request.url.params)) + return httpx.Response(200, json={}) + + client = make_client(handler) + with pytest.warns(DeprecationWarning, match="details"): + await call(client, "get_financials", "DE1", include_line_items=True) + with pytest.warns(DeprecationWarning): + await call(client, "get_financials", "DE1", include_line_items=True, details=False) + assert seen == [{"details": "true"}, {"details": "false"}] + + +# --------------------------------------------------------------------------- +# Live calls are never billed twice +# --------------------------------------------------------------------------- + +LIVE_CALLS = [ + ("get_company", {"fetch_realtime": True}), + ("get_shareholders", {"fetch_realtime": True}), + ("get_ubo", {"fetch_realtime": True}), + ("get_history", {"fetch_realtime": True}), + ("list_documents", {}), + ("download_document", {"file_type": "shareholder_list"}), +] + + +@pytest.mark.parametrize("method,kwargs", LIVE_CALLS) +async def test_live_calls_wait_at_least_150_seconds(make_client, method, kwargs): + seen = [] + + def handler(request): + seen.append(request.extensions["timeout"]["read"]) + return httpx.Response(200, json={}) + + await call(make_client(handler), method, "DE1", **kwargs) + await call(make_client(handler, timeout=200.0), method, "DE1", **kwargs) + await call(make_client(handler, timeout=200.0), method, "DE1", **kwargs, timeout=5.0) + assert seen == [150.0, 200.0, 5.0] + + +async def test_ordinary_calls_keep_the_client_timeout(make_client): + seen = [] + + def handler(request): + seen.append(request.extensions["timeout"]["read"]) + return httpx.Response(200, json={"data": []}) + + client = make_client(handler) + await call(client, "get_company", "DE1") + await call(client, "search", q="acme") + await call(client, "search", q="acme", fetch_realtime=True) + await call(client, "autocomplete", "acme", fetch_realtime=True, timeout=9.0) + assert seen == [30.0, 30.0, 150.0, 9.0] + + +@pytest.mark.parametrize("method,kwargs", LIVE_CALLS) +async def test_timed_out_live_call_is_not_retried(make_client, method, kwargs): + calls = [] + + def handler(request): + calls.append(request) + raise httpx.ReadTimeout("timed out", request=request) + + with pytest.raises(APITimeoutError) as exc: + await call(make_client(handler, max_retries=2), method, "DE1", **kwargs) + assert len(calls) == 1 + assert "billed" in str(exc.value) + assert "higher timeout" in str(exc.value) + + +async def test_timed_out_ordinary_get_is_retried(make_client): + calls = [] + + def handler(request): + calls.append(request) + raise httpx.ReadTimeout("timed out", request=request) + + with pytest.raises(APITimeoutError) as exc: + await call(make_client(handler, max_retries=2), "get_company", "DE1") + assert len(calls) == 3 + assert "billed" not in str(exc.value) + + +@pytest.mark.parametrize("failure", [httpx.ReadError, httpx.RemoteProtocolError, httpx.WriteError]) +async def test_live_call_broken_after_sending_is_not_retried(make_client, failure): + calls = [] + + def handler(request): + calls.append(request) + raise failure("connection lost", request=request) + + with pytest.raises(APIConnectionError) as exc: + await call(make_client(handler, max_retries=2), "get_ubo", "DE1", fetch_realtime=True) + assert not isinstance(exc.value, APITimeoutError) + assert "billed" in str(exc.value) + assert len(calls) == 1 + + +@pytest.mark.parametrize("failure", [httpx.ConnectError, httpx.ConnectTimeout]) +async def test_live_call_that_never_left_is_retried(make_client, failure): + calls = [] + + def handler(request): + calls.append(request) + if len(calls) == 1: + raise failure("refused", request=request) + return httpx.Response(200, json={}) + + await call(make_client(handler, max_retries=2), "get_ubo", "DE1", fetch_realtime=True) + assert len(calls) == 2 + + +async def test_gateway_timeout_retried_only_for_ordinary_calls(make_client): + calls = [] + + def handler(request): + calls.append(request) + return httpx.Response(504, json=problem("server-error", 504)) + + with pytest.raises(ServerError): + await call(make_client(handler, max_retries=2), "get_company", "DE1", fetch_realtime=True) + assert len(calls) == 1 + with pytest.raises(ServerError): + await call(make_client(handler, max_retries=2), "get_company", "DE1") + assert len(calls) == 4 + + +def test_should_retry_live_rules(): + base = {"method": "GET", "attempt": 0, "max_retries": 2} + assert not should_retry(status_code=None, live=True, **base) + assert should_retry(status_code=None, live=True, request_sent=False, **base) + assert not should_retry(status_code=504, live=True, **base) + assert not should_retry(status_code=408, live=True, **base) + assert should_retry(status_code=503, live=True, **base) + assert should_retry(status_code=429, live=True, **base) + assert should_retry(status_code=None, **base) + assert should_retry(status_code=504, **base) + + +# --------------------------------------------------------------------------- +# Retry-After +# --------------------------------------------------------------------------- + +async def test_long_retry_after_raises_at_once(make_client): + calls = [] + + def handler(request): + calls.append(request) + return httpx.Response( + 429, json=problem("rate-limit-exceeded", 429), headers={"Retry-After": "86400"}, + ) + + started = time.monotonic() + with pytest.raises(RateLimitError) as exc: + await call(make_client(handler, max_retries=2), "autocomplete", "sap") + assert exc.value.retry_after == 86_400.0 + assert len(calls) == 1 + assert time.monotonic() - started < 1.0 + + +async def test_short_retry_after_is_honoured(make_client): + calls = [] + + def handler(request): + calls.append(request) + if len(calls) == 1: + return httpx.Response( + 429, json=problem("rate-limit-exceeded", 429), headers={"Retry-After": "3"}, + ) + return httpx.Response(200, json={"data": []}) + + await call(make_client(handler, max_retries=2), "autocomplete", "sap") + assert len(calls) == 2 + + +def test_should_retry_caps_retry_after(): + base = {"method": "GET", "status_code": 429, "attempt": 0, "max_retries": 2} + assert should_retry(retry_after=20.0, **base) + assert not should_retry(retry_after=21.0, **base) + assert should_retry(**base) + + +# --------------------------------------------------------------------------- +# on_response +# --------------------------------------------------------------------------- + +async def test_on_response_sees_every_response(make_client): + seen: list[ResponseInfo] = [] + calls = [] + + def handler(request): + calls.append(request) + if len(calls) == 1: + return httpx.Response( + 429, json=problem("rate-limit-exceeded", 429), + headers={"Retry-After": "0", "X-Credits-Charged": "0"}, + ) + return httpx.Response( + 200, json={"data": []}, + headers={"X-Request-Id": "req-1", "X-Credits-Charged": "3"}, + ) + + client = make_client(handler, max_retries=1, on_response=seen.append) + await call(client, "search", q="acme") + assert [(i.status, i.request_id, i.credits_charged) for i in seen] == [ + (429, "req-body", 0), (200, "req-1", 3), + ] + assert seen[1].headers["x-credits-charged"] == "3" # case-insensitive + + +async def test_on_response_on_errors_without_credits_header(make_client): + seen: list[ResponseInfo] = [] + + def handler(request): + return httpx.Response(404, json=problem("not-found", 404)) + + with pytest.raises(NotFoundError): + await call(make_client(handler, on_response=seen.append), "get_company", "DE1") + assert len(seen) == 1 and seen[0].credits_charged is None and seen[0].status == 404 + + +async def test_on_response_errors_propagate(make_client): + class Boom(Exception): + pass + + def hook(info): + raise Boom + + def handler(request): + return httpx.Response(200, json={}) + + with pytest.raises(Boom): + await call(make_client(handler, on_response=hook), "get_company", "DE1") + + +async def test_async_on_response_may_be_a_coroutine(): + seen = [] + + async def hook(info): + seen.append(info.credits_charged) + + def handler(request): + return httpx.Response(200, json={}, headers={"X-Credits-Charged": "2"}) + + async with AsyncFirmenData( + on_response=hook, + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) as fd: + await fd.get_company("DE1") + assert seen == [2] + + +# --------------------------------------------------------------------------- +# iter_search +# --------------------------------------------------------------------------- + +def hit(n): + return {"eu_id": f"DE{n}", "display_name": f"Company {n}"} + + +async def test_iter_search_follows_cursors_with_cursor_and_limit_only(make_client): + pages = [ + {"data": [hit(1), hit(2)], "pagination": {"next_cursor": "c1", "has_more": True}}, + {"data": [hit(3)], "pagination": {"next_cursor": "c2", "has_more": True}}, + {"data": [hit(4)], "pagination": {"next_cursor": None, "has_more": False}}, + ] + seen = [] + + def handler(request): + seen.append(dict(request.url.params)) + return httpx.Response(200, json=pages[len(seen) - 1]) + + hits = await collect( + make_client(handler), "iter_search", + bundesland=["Bayern"], q="acme", fetch_realtime=True, limit=2, + ) + assert [h["eu_id"] for h in hits] == ["DE1", "DE2", "DE3", "DE4"] + assert seen == [ + {"bundesland": "Bayern", "q": "acme", "fetch_realtime": "true", "limit": "2"}, + {"cursor": "c1", "limit": "2"}, + {"cursor": "c2", "limit": "2"}, + ] + + +async def test_iter_search_stops_at_max_results(make_client): + seen = [] + + def handler(request): + seen.append(dict(request.url.params)) + return httpx.Response(200, json={ + "data": [hit(1), hit(2), hit(3)], + "pagination": {"next_cursor": "more", "has_more": True}, + }) + + hits = await collect(make_client(handler), "iter_search", q="acme", max_results=2) + assert [h["eu_id"] for h in hits] == ["DE1", "DE2"] + assert seen == [{"q": "acme", "limit": "2"}] + + +async def test_iter_search_never_passes_the_cap(make_client): + counter = {"n": 0, "calls": 0} + + def handler(request): + counter["calls"] += 1 + start = counter["n"] + counter["n"] += 50 + return httpx.Response(200, json={ + "data": [hit(i) for i in range(start, start + 50)], + "pagination": {"next_cursor": f"c{start}", "has_more": True}, + }) + + hits = await collect(make_client(handler), "iter_search", max_results=20_000) + assert len(hits) == 10_000 + assert counter["calls"] == 200 + + +async def test_iter_search_is_lazy(make_client): + seen = [] + + def handler(request): + seen.append(request) + return httpx.Response(200, json={"data": [], "pagination": {"has_more": False}}) + + client = make_client(handler) + client.iter_search(q="acme") + assert seen == [] + + +# --------------------------------------------------------------------------- +# Typed request bodies +# --------------------------------------------------------------------------- + +async def test_create_subscription_and_test_delivery_bodies(make_client): + seen = [] + + def handler(request): + seen.append((request.method, request.url.path, request.read())) + return httpx.Response(200, json={"receiver_succeeded": True}) + + client = make_client(handler, api_key="k") + await call( + client, "create_subscription", + eu_id="DEF1103R.HRB12300B", subscription_type="details", cadence="weekly", + ) + await call( + client, "test_delivery", + eu_id="DEF1103R.HRB12300B", subscription_type="details", + notification_type="webhook", webhook_url="https://example.com/hook", + ) + assert [(m, p) for m, p, _ in seen] == [ + ("POST", "/v1/subscriptions"), ("POST", "/v1/subscriptions/test"), + ] + assert b'"cadence":"weekly"' in seen[0][2].replace(b" ", b"") + assert b'"notification_type":"webhook"' in seen[1][2].replace(b" ", b"")