# Getting started

- **API key:** If you don't have an API key, head over to [firmenakte.at/app/api](https://firmenakte.at/app/api).
- **Install:** `npm i @firmenakte/api-client`
- **MCP:** Point your AI agent (Cursor, Claude, …) at the MCP server **mcp.firmenakte.at/mcp** and it
  queries companies, people and change feeds through this API directly, on your key and your quota.

## Quickstart

One request, one minute. `34853f` is PORR AG — a real Firmenbuchnummer, so this works as written.

```sh
export FIRMENAKTE_KEY=<your key>
curl -H "x-api-key: $FIRMENAKTE_KEY" https://api.firmenakte.at/api/v1/businesses/34853f
```

```json
{
  "fnr": "34853f",
  "name": "PORR AG",
  "legalForm": { "code": "AG", "text": "Aktiengesellschaft" },
  "court": { "code": "90001", "text": "Wien" },
  "isActive": true,
  "addresses": [{ "street": "Absberggasse", "zipCode": "1100", "city": "Wien" }],
  "oenaces": [{ "code": "N 70.10-0", "kurztitel": "Führung v. Unternehmen" }],
  "latestKeyFigures": { "employees": 561, "bilanzSumme": 1775949626.05 }
}
```

Every request needs the `x-api-key` header: without it you get `401`, and once your plan's monthly
quota is spent you get `429` with a `Retry-After`. Every endpoint costs exactly one request.

From here: `GET /businesses?search=porr` finds a company by name, `GET /businesses` with filters
screens a whole market, and `GET /globalchanges?sinceId=…` polls for what has changed since.

## Glossary

These identifiers are the currency of the whole API. They are matched **exactly** as stored — the one
exception is the `fnr` _filter_, below — so pass them back in the form the API gave you.

- **`fnr`** — Firmenbuchnummer, the id of an Austrian company, normalised: lower case, no spaces, no
  leading zeros, no `FN` prefix. `34853f`, never `FN 34853 f` and never `034853F`. It is the path
  segment of `/businesses/{fnr}` and a filter on most other endpoints. As a filter — on
  `/businesses`, on `/people`, on `/graph` and on every change feed — the value you send is
  normalised the same way before matching — trimmed, spaces and leading zeros removed — so
  `?fnr=034853 f` finds the same company as `?fnr=34853f`. Case is never folded (`034853F` matches
  nothing), and the path segment is matched exactly, so `/businesses/034853f` answers 404.
- **`vnr`** — Vollzugsnummer, the id of a single Firmenbuch legal record (Eintragung). Unique per
  company, so a record is identified by the pair `(fnr, vnr)`.
- **`gisaZahl`** — the number of one trade licence (Gewerbeberechtigung) in the GISA register. A
  company usually holds several, each with its own wording, address and status.
- **`tradeKey`** — the Gewerbeschlüssel: the code of a _kind_ of trade in the GISA catalogue, e.g.
  `500267`. So `gisaZahl` is one company's licence and `tradeKey` is the trade it is a licence for.
  Browse the catalogue at `GET /trades`; filter companies by trade with `gisaLicenseKey`.
- **`documentKey`** — the id of a document filed with the Firmenbuch, a Jahresabschluss for instance.
  Download it with `GET /documents/download/{documentKey}`.
- **`personId`** — a synthetic id for a person, built as `firstname-lastname-birthdate`: lower case,
  inner spaces replaced by hyphens, the birthdate as `yyyy-mm-dd`, and `x` for a part the register
  does not give — `max-mustermann-1970-01-01`, or `max-mustermann-x` without a birthdate.
- **Edikt** — a notice published in the Ediktsdatei, the Austrian courts' public register; most
  importantly insolvency proceedings. `hasEdikt` on a company means at least one is on file for it.
- **Scheinunternehmen** — a company on the Finanzministerium's published list of bogus companies
  (BMF LSU). `isScheinunternehmen` says whether it is on the list, and
  `scheinunternehmenKundmachungen` on the detail response carries the publications behind it.
- **OENACE** — the Austrian form of the EU's NACE industry classification. A company carries its code
  as `oenaceNumericCode` (e.g. `46649`) and the full entries under `oenaces`; the catalogue is
  `GET /oenace`, and `oenaceCode` filters `/businesses` by it.

## Pagination

Every list endpoint takes `page` and `pageSize` and answers with the same envelope: `data`, plus
`pageNumber`, `pageSize`, `totalRecords` and `totalPages`. Omitting them is not an error — `page`
defaults to `1` and `pageSize` to `10`, except on the change feeds (`/globalchanges`,
`/businesses/changes`, `/documents/changes`, `/edikte/changes`, `/trades/changes`), where `pageSize`
defaults to `100`. `totalRecords` counts every match, not the rows on this page.

On the five change feeds it counts only as far as `page` reaches: `page` stops at 1000, so
`totalRecords` stops at `1000 × pageSize` and `totalPages` at `1000`. Below that cap it is the exact
number of matches, as everywhere else — every `fnr` query and every caught-up `sinceId` poll is — and
at it, it means "at least this many". Counting a register of millions of changes on every poll cost
more than delivering the page did, and no page past 1000 can be requested anyway.

Offset paging is safe on a static list, but a change feed grows while you read it — see below for how
to poll one without skipping a change.

## Polling for changes

The five change feeds — `/globalchanges` and the typed `/businesses/changes`, `/documents/changes`,
`/edikte/changes`, `/trades/changes` — are all ordered by **ascending `id`**, and that `id` is the
cursor. Ids are assigned once and never reused, and the same id identifies a change in the typed feed
and in `/globalchanges`.

The recipe, and the only reliable one:

1. Request the feed with `sinceId=<the highest id you have stored>`, `page=1` and a fixed `pageSize`.
2. Read `data`, then request `page=2`, `page=3`, … with **the same `sinceId`**, until a page comes
   back with fewer than `pageSize` rows.
3. Store the highest `id` you saw, and start the next poll from it.

`sinceId` is **exclusive**: the change with that id is not returned, so nothing is delivered twice.
Because a new change always takes a higher id than everything already in the feed, changes that arrive
in the middle of a poll are appended to the end — they cannot shift a page you have already read, so
walking the pages misses nothing.

`since` is a different filter, not a cursor: it is inclusive, filters on when _we recorded_ the change
rather than on the date the register carries, and applies together with `sinceId` when both are sent.
It is useful for a first backfill; for polling, use `sinceId`.

**Paging without `sinceId` is not a feed.** `page` stops at 1000, and an offset is not a watermark:
the next poll has nothing to resume from, and rows arriving between two requests move the whole feed
under the offsets.

One honest caveat: two changes being written at the same moment can become visible out of id order for
a moment, so a change can appear just after a higher id was already delivered. A consumer that must
never miss a change — an insolvency notice, say — resumes each poll from the _previous_ poll's
watermark instead of the newest one, which costs one cycle of overlap, and de-duplicates by `id`.

```sh
# Everything since change 41 200, one page at a time.
curl -H "x-api-key: $FIRMENAKTE_KEY" \
  "https://api.firmenakte.at/api/v1/edikte/changes?sinceId=41200&page=1&pageSize=100"
```

## Reading the numbers

The financial figures — `latestKeyFigures`, and every entry of `parsedJahresabschluesse` — are read
from a company's filed annual accounts, one filing at a time. `null` always means **this position was
not reported in that filing**, never zero and never "the company has none": most companies file
abridged accounts, so most positions are absent for most companies. Do not sum or average a `null`
as `0`.

Which year a figure belongs to is the `documentDate` of the object carrying it — the date of the
filing it was parsed from. `latestKeyFigures` comes from whichever filing has the latest
`documentDate`, so two companies are only comparable if their `documentDate` is.

## Errors

Every endpoint can answer with an error, and there are three body shapes:

- **`401` and `429`** are written before your request reaches an endpoint, so they are plain
  `application/json`: `{ "error": "…" }` (`ApiError`).
- **`400`, `404` and `500`** come from the API itself as RFC 7807 `application/problem+json`
  (`ProblemDetails`): `title`, `status`, `detail`, `instance`, plus a `traceId` worth quoting in a
  support request. A model validation failure adds `errors`, keyed by field, and a body written by
  the exception handler — every `500`, and the `400`/`404`s that come from a thrown exception — also
  carries `correlationId`. That value is on **every** response as the `X-Correlation-ID` header,
  echoing the `x-correlation-id` you sent or a new one if you sent none, so quote the header rather
  than waiting for the field.
- **`503`** occurs only on `GET /documents/download/{documentKey}` and is neither: a `text/plain`
  German sentence, `Firmenbuch API aktuell nicht verfügbar`. Do not parse it as JSON — retry the
  download later.

| Status | When                                                                                     |
| ------ | ---------------------------------------------------------------------------------------- |
| 400    | a malformed or out-of-range parameter, or a filter value the endpoint does not accept    |
| 401    | `x-api-key` missing, unknown or no longer active                                         |
| 404    | no resource matches the identifier                                                       |
| 429    | the monthly quota is exhausted, or too many requests in a short burst — see below        |
| 500    | unexpected server error                                                                  |
| 503    | only on document downloads: the Firmenbuch document service is unreachable — retry later |

## Quotas & rate limits

**Monthly quota.** Each plan carries a monthly request quota; see
[firmenakte.at/app/api](https://firmenakte.at/app/api) for your current usage and limits. The count is kept **per
user, not per key** — issuing or rotating a key does not reset it — and it resets at 00:00 UTC on
the first of the month.

The quota is checked after your key is authenticated and before the endpoint runs, so **a request
is billed whatever it answers**: a 400, a 404 and a 500 all count. A request rejected with 401, or
rejected by the burst limit below, does not. Once the quota is used up, every request answers `429`
with `{"error":"Monthly request quota exceeded…"}` and a `Retry-After` header holding the seconds
until the reset.

**Burst limit.** Independently of the quota, a sliding window caps how fast a single key may call —
1000 requests per 60 seconds by default. Over it the API also answers `429`, with the same body
shape and `Retry-After: 60`. These requests never reach the quota counter, so they are not billed.

`Retry-After` is what tells the two apart: 60 seconds is the burst limit, anything larger is the
monthly quota.
