Skip to main content

firmendata-python

Official Python client for the firmendata API — data on German and Swiss companies from the Unternehmensregister, Handelsregister and Swiss commercial register: register search, parsed annual financial statements, company profiles, register documents, and ownership chains for KYC.

PyPI Python License: MIT

pip install firmendata

Try it without signing up

Company-name autocomplete is free and needs no API key:

from firmendata import FirmenData

for hit in FirmenData().autocomplete("siemens")["data"]:
    print(hit["eu_id"], hit["display_name"])

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.

With an API key

Create one at firmendata.com — the free plan includes 100 credits.

from firmendata import FirmenData

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.

results = fd.search(
    bundesland=["Bayern", "Baden-Württemberg"],
    industry_slug=["manufacturing"],
    total_assets_min=1_000_000,
    legal_status=["active"],
    sort="total_assets",
    limit=25,
)

for hit in results["data"]:
    print(hit["display_name"], hit["address"]["city"], hit["total_assets"])

if results["pagination"]["has_more"]:
    next_page = fd.search(cursor=results["pagination"]["next_cursor"])

Pass next_cursor back unchanged — cursors are opaque and signed, and an edited one is rejected. Unknown filter names, unknown values and inverted ranges (revenue_min above revenue_max) raise ValidationError instead of being ignored.

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.

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" or "CH").

Filtering on size? Use total_assets, not revenue. Small and medium-sized German companies file abridged accounts — a balance sheet, but no profit-and-loss statement and no headcount. A revenue or employee bound therefore narrows your results to the minority that publish a full P&L, while the balance-sheet total is available for every filing company.

Company profile

eu_id = "DEB1103R_HRB123456"  # 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.

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.

financials = fd.get_financials(eu_id)

summary = financials["summary"]
if summary:
    print(summary["latest_fiscal_year"], summary["latest_total_assets"])

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 per-year array to the most recent fiscal years:

full = fd.get_financials(eu_id, include_line_items=True, years=5)

employee_history and the underlying financial_publications are always included; relationships["subsidiaries"] lists the first 25, with subsidiaries_total for the count.

summary is None when nothing is on file. Within it, figures resolve to the most recent filing that actually carries each one, so revenue and profit can come from different fiscal years — don't assume two share a year when computing a ratio.

Documents

documents = fd.list_documents(eu_id)
doc = fd.download_document(eu_id, file_type="register_extract_current")

for item in documents["data"]:
    if item.get("document_id") and not item["is_latest"]:
        older = fd.download_document(
            eu_id, file_type=item["type"], document_id=item["document_id"],
        )
        break

Aktueller and Chronologischer Abdruck, Gesellschafterliste, Satzung, Anmeldung and Musterprotokoll, as presigned download URLs.

list_documents() checks the registry live and returns current and older versions with labels, dates, is_latest, stored-copy metadata, coverage and freshness. It costs 5 credits; Swiss, empty and registry-unreachable answers are unbilled. Swiss companies return an empty list with coverage["status"] == "not_applicable".

Pass a doc_<int> identifier from the list as document_id with its matching file_type to download a specific DK 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.

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 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 is the right trade for a KYC check.

cap = fd.get_shareholders(eu_id, fetch_realtime=True)

if cap["coverage"]["status"] == "available":
    for s in cap["as_of_snapshot"]["shareholders"]:
        print(s["display_name"], s["share_percent"])

ubo = fd.get_ubo(eu_id, fetch_realtime=True)
print(ubo["coverage"]["status"], ubo["beneficial_owners"])

What limits these:

  • Only GmbH, UG and gGmbH file a Gesellschafterliste. For an AG, KG, e.K. or any other form there is no cap table to read and coverage["status"] is not_applicable — not an error, and not something a retry will fix.
  • Without fetch_realtime, not_filed does not mean "never filed." It means no parsed cap table is on hand for that company yet. Re-request with the flag before concluding anything about a company's ownership.
  • Always branch on coverage["status"], never on an empty list. The two endpoints have different vocabularies: get_shareholders → available | not_filed | not_applicable | token_limit_reached (the filing was too large to parse); get_ubo → available | partial | not_filed | not_applicable. partial is the one to handle: an unresolved branch could still hide a beneficial owner, so read potential_beneficial_owners and coverage["reason"] rather than treating the result as complete.
  • An empty beneficial_owners is a real answer, not a failure: it means nobody crosses the 25% threshold. Fictional UBO under §3 Abs. 2 S. 5 GwG is not surfaced.
  • Attribution is all-or-nothing, not multiplicative. A holds 60% of H and H holds 30% of the root → A is a UBO at 30%, not 18%. Each link is independently tested against the 25% threshold, so a sub-threshold link breaks the chain entirely.

Subscriptions

Get notified when a company's data changes, instead of polling:

sub = fd.create_subscription(
    eu_id=eu_id,
    subscription_type="shareholders",  # or details, history, ubo, doc_*
    cadence="weekly",                  # immediately | daily | weekly | monthly
    notification_type="webhook",
    webhook_url="https://example.com/hooks/firmendata",
)

Webhook bodies are HMAC-SHA256 signed — verify X-Firmendata-Signature against the secret returned at creation. Omit notification_type to poll list_events() on your own schedule instead.

Async

Same methods, same semantics — every method above has an awaitable twin:

import asyncio
from firmendata import AsyncFirmenData

async def main():
    async with AsyncFirmenData(api_key="firmendata_live_...") as fd:
        company = await fd.get_company("DEB1103R_HRB123456")
        print(company["display_name"])

asyncio.run(main())

Errors

Every failure is a typed exception carrying the API's RFC 7807 problem detail, including a request_id you can quote to support.

from firmendata import FirmenData, InsufficientCreditsError, RateLimitError

try:
    fd.get_ubo(eu_id)
except InsufficientCreditsError:
    ...                      # top up or upgrade
except RateLimitError as e:
    ...                      # e.retry_after is the server's own hint
Exception Status Meaning
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
ConflictError 409 Conflicts with existing state
ValidationError 422 Bad parameters — see .errors ({"param", "message"} each)
RateLimitError 429 Retry budget exhausted — see .retry_after
ServerError 5xx Retried automatically for idempotent calls
APIConnectionError / APITimeoutError — No response at all

Retries

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.
  • 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.
  • 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.

Types

Responses are plain dictionaries described by generated TypedDicts, so editors complete every field and mypy checks them — with no pydantic dependency to collide with your own. The only runtime requirement is httpx.

Both src/firmendata/types.py and src/firmendata/params.py are generated from contracts/openapi.v1.json, a vendored copy of the 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.

Development

pip install -e '.[dev]'
pytest              # no network, no credentials
mypy && ruff check

License

MIT — see LICENSE.

Metadata

Release files for firmendata 1.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for firmendata 1.2.0
File Size Uploaded
firmendata-1.2.0.tar.gz 104.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for firmendata 1.2.0
File Interpreter ABI Platform
firmendata-1.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 136.1 kB

Release files / firmendata-1.2.0.tar.gz

Download URL firmendata-1.2.0.tar.gz
Size 104.7 kB
Tags Source
SHA-256 checksum
How to use checksums
4433171aeca626c9e906cee95283c4226792b41bc391710d45b337109613ff7a
BLAKE2b-256 checksum
How to use checksums
f27c7a1c2affc0b705d5c33e2ec8ab45dc160b82ed4289f997648ebb97208d8d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.

Transparency log

Release files / firmendata-1.2.0-py3-none-any.whl

Download URL firmendata-1.2.0-py3-none-any.whl
Size 31.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ef350d185037810e7314cf5d65bbf45bdb34a63f93bd4625cd8598c6e2d623cb
BLAKE2b-256 checksum
How to use checksums
52d9ead30c2c6d5038a4e96ef881a1b21d772f1b36d1969152612e8a578ae362
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.

Transparency log

Release history Release notifications | RSS feed

1.2.1

2 release files

This release

1.2.0 This release

2 release files

1.0.0

2 release files

0.2.0

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page