Official TypeScript/JavaScript client for the firmendata API — data on 2.4 million German companies from the Unternehmensregister and Handelsregister: register search, parsed annual financial statements, company profiles, register documents, and ownership chains for KYC.
npm install firmendataZero runtime dependencies. Built on the platform fetch, so it runs
unchanged on Node 18+, Bun, Deno, Cloudflare Workers and in the browser, and
adds nothing to your dependency tree.
Company-name autocomplete is free and needs no API key:
import { FirmenData } from 'firmendata';
const fd = new FirmenData();
const { data } = await fd.autocomplete('siemens');
for (const hit of data) {
console.log(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.
Create a key at firmendata.com — the free plan includes 100 credits.
const fd = new FirmenData({ apiKey: process.env.FIRMENDATA_API_KEY });The main entry point: 37 filters over all 2.4 million companies. Different filters combine with AND, repeated values with OR.
const results = await 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 (const hit of results.data) {
console.log(hit.display_name, hit.address.city, hit.total_assets);
}
if (results.pagination.has_more) {
const next = await fd.search({ cursor: results.pagination.next_cursor });
}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.
Filtering on size? Use
total_assets, notrevenue. 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.
const euId = 'DEB1103R_HRB123456'; // from search or autocomplete
const company = await fd.getCompany(euId);
const history = await fd.getHistory(euId); // chronological register entriesIdentity and seat, register reference, legal status resolved from the merged Handelsregister and Insolvenzbekanntmachungen timelines, industry classification, contact details and web presence.
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.
const { summary, history } = await fd.getFinancials(euId);
console.log(summary?.latest_fiscal_year, summary?.latest_total_assets);
for (const year of history.metrics) {
console.log(year.year, year.balance_sheet_total, year.revenue, year.profit);
}history also carries the structured profit_and_loss, assets and
liabilities_and_equity rows as filed, plus employee_history and the
underlying financial_publications.
summary is null 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.
const doc = await fd.downloadDocument(euId, { fileType: 'CD' });Aktueller and Chronologischer Abdruck, Gesellschafterliste, Satzung, Anmeldung and Musterprotokoll, as presigned download URLs.
Cap tables and beneficial-owner chains, for KYC and AML workflows.
Pass fetchRealtime: 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 getUbo, 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.
const cap = await fd.getShareholders(euId, { fetchRealtime: true });
if (cap.coverage.status === 'available') {
for (const s of cap.as_of_snapshot.shareholders) {
console.log(s.display_name, s.share_percent);
}
}
const ubo = await fd.getUbo(euId, { fetchRealtime: true });
console.log(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.statusisnot_applicable— not an error, and not something a retry will fix. - Without
fetchRealtime,not_fileddoes 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 array. The two endpoints have different vocabularies:getShareholders→available|not_filed|not_applicable|token_limit_reached(the filing was too large to parse);getUbo→available|partial|not_filed|not_applicable.partialis the one to handle: an unresolved branch could still hide a beneficial owner, so readpotential_beneficial_ownersandcoverage.reasonrather than treating the result as complete. - An empty
beneficial_ownersis 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.
Get notified when a company's data changes, instead of polling:
const sub = await fd.createSubscription({
eu_id: euId,
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
listEvents() on your own schedule instead.
Every failure is a typed error carrying the API's RFC 7807 problem detail,
including a requestId you can quote to support.
import { InsufficientCreditsError, RateLimitError } from 'firmendata';
try {
await fd.getUbo(euId);
} catch (err) {
if (err instanceof InsufficientCreditsError) {
// top up or upgrade
} else if (err instanceof RateLimitError) {
console.log('retry after', err.retryAfter, 'seconds');
}
}| Error | 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 |
RateLimitError |
429 | Retry budget exhausted — see .retryAfter |
ServerError |
5xx | Retried automatically for idempotent calls |
APIConnectionError / APITimeoutError |
— | No response at all |
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-Afteris used verbatim. - 5xx and connection failures are retried only for idempotent methods. A
createSubscriptionthat 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 new FirmenData({ maxRetries }); 0 disables it.
Every request and response type is derived from the OpenAPI contract, not
hand-written. src/schema.ts is generated from
contracts/openapi.v1.json — a vendored copy of
the published spec — and src/types.ts projects the public aliases out of it:
npm run generateCI regenerates and fails if the result differs from what is committed, so the SDK cannot silently drift from the API it targets.
new FirmenData({
apiKey: '...', // optional — omit for the free autocomplete tier
baseUrl: '...', // default https://api.firmendata.com
timeoutMs: 30_000,
maxRetries: 2,
fetch: customFetch, // injection point for tests or a custom transport
});npm install
npm test # no network, no credentials
npm run typecheck
npm run build- API reference — https://api.firmendata.com/v1/docs
- Python SDK — https://github.com/FirmenData/firmendata-python
- n8n node — https://github.com/FirmenData/n8n-nodes-firmendata
- MCP server (for AI agents) —
https://mcp.firmendata.com/mcp - Website — https://firmendata.com
MIT — see LICENSE.