DEV Community

Devil Scrapes
Devil Scrapes

Posted on

A KRS number is not a number, and Poland's register has no 404

Quick answer

Poland's Ministry of Justice publishes the whole KRS company register as a free, keyless JSON API. It is genuinely good open data, and it has one property that will corrupt your dataset before you notice:

A KRS number is not a number. It is a 10-digit zero-padded string, and 0000121234 is a different company from 121234 the moment anything in your pipeline casts it to an integer โ€” a spreadsheet export, a JSON round-trip, an overeager int().

Also: there is no 404. Ask for a company that does not exist and you get a 4xx with no documented "not found" shape, which means "unknown company" and "our request was malformed" arrive looking identical.

The leading zeros are load-bearing ๐Ÿ”ข

KRS numbers are always ten digits. Most real ones start with 0000:

0000121234    โ† a real company
121234        โ† the same digits, no longer a valid KRS number
Enter fullscreen mode Exit fullscreen mode

The API takes the number as a path segment, so the request either matches or it doesn't:

https://api-krs.ms.gov.pl/api/krs/OdpisAktualny/0000121234?rejestr=P&format=json
Enter fullscreen mode Exit fullscreen mode

There is no clever fix. There is only a rule, applied at the boundary and never relaxed:

def _normalize_krs_number(raw):
    """Strip whitespace, reject non-digit/over-length input, zero-pad to 10 digits.

    Never casts to ``int`` โ€” leading zeros are load-bearing (spec REQ-2).
    """
    text = str(raw).strip()
    if not text.isdigit():
        raise ValueError(f"KRS number must contain only digits, got {raw!r}")
    if len(text) > KRS_NUMBER_LENGTH:
        raise ValueError(f"KRS number must be at most {KRS_NUMBER_LENGTH} digits, got {raw!r}")
    return text.zfill(KRS_NUMBER_LENGTH)
Enter fullscreen mode Exit fullscreen mode

Three deliberate choices in nine lines:

  • zfill, not int() then format. We accept 121234 from a customer whose Excel already ate the zeros, and repair it back to 0000121234. That is the one place guessing is safe, because ten digits is a fixed width and there is exactly one way to pad it.
  • Reject non-digits instead of stripping them. PL 0000121234 is a bad input, not a repairable one, and telling the customer beats silently looking up something else.
  • Reject over-length. Eleven digits is not a KRS number that needs trimming; it is a different identifier โ€” probably a NIP or a REGON โ€” and truncating it would produce a confident lookup of an unrelated company.

The identifier stays a string end-to-end, all the way into the dataset row. This is the boring discipline that separates a register scraper you can run a KYC process on from one that occasionally reports the wrong company.

The envelope is ~50 KB nested four deep ๐Ÿช†

OdpisAktualny means "current extract" โ€” the legal document, rendered as JSON. It is shaped like a Polish court filing, not like a row:

odpis
โ””โ”€โ”€ dane
    โ”œโ”€โ”€ dzial1
    โ”‚   โ”œโ”€โ”€ danePodmiotu          โ†’ name, legal form, KRS, NIP, REGON
    โ”‚   โ”œโ”€โ”€ siedzibaIAdres        โ†’ address
    โ”‚   โ”œโ”€โ”€ przedmiotDzialalnosci โ†’ PKD activity codes
    โ”‚   โ””โ”€โ”€ kapital               โ†’ share capital
    โ”œโ”€โ”€ dzial2
    โ”‚   โ””โ”€โ”€ reprezentacja         โ†’ board members, representation rules
    โ””โ”€โ”€ dzial6
        โ””โ”€โ”€ ...                   โ†’ bankruptcy / liquidation status
Enter fullscreen mode Exit fullscreen mode

dzial means "division", and the divisions are the statutory sections of the register โ€” so the nesting is legally meaningful and nobody is going to flatten it for you. To answer "what is this company called and where is it," you traverse four levels through Polish-language keys, and the keys you need are spread across three separate divisions.

That is fine once. It is not fine for 200 companies, and it is genuinely miserable if you are trying to do it in a spreadsheet or a no-code tool.

So the Actor flattens it to one row per KRS number, and lets you ask for only the sections you want:

identity ยท address ยท representatives ยท pkd ยท capital ยท status
Enter fullscreen mode Exit fullscreen mode

That switch is about transfer cost, not tidiness. The full envelope is ~50 KB; if all you need is name and status for a 200-company screen, there is no reason to pay to move the representation rules and every historical PKD code as well.

Per-section fault isolation goes with it. A missing sub-key logs a warning and leaves that field empty โ€” it does not take down the row, and one odd filing does not take down the run. Government registers are full of records that predate the current schema, and a shape you have never seen is a normal Tuesday, not an exception.

There is no 404

This is the part worth stealing even if you never touch KRS.

Ask for a KRS number that does not exist and the API does not return a documented 404 with a "no such record" body. It returns a 4xx. Which 4xx is not documented, and neither is the body.

That collapses two very different situations into one response:

  • the company genuinely is not in the register
  • our request was wrong โ€” bad registry type, malformed number, changed route

A scraper that treats all 4xx as failure reports "error" for legitimately-absent companies and pollutes your run with noise. One that treats all 4xx as not-found will happily report found: false for all 200 inputs on the day the Ministry changes the URL โ€” a total outage that looks exactly like a clean, successful run of 200 unknown companies.

We chose the second behaviour, deliberately and with the reason written down next to it:

if HTTP_CLIENT_ERROR_MIN <= status < HTTP_CLIENT_ERROR_MAX:
    # KRS has no distinct 404 documented live; any other 4xx here is
    # treated as the legitimate "no such record" answer, never retried.
    return KrsFetchOutcome(found=False, body=None, error=None, source_url=url)
Enter fullscreen mode Exit fullscreen mode

What makes that safe rather than reckless is the rest of the row. Every result carries found: true|false and the exact source_url we called โ€” including for inputs rejected before any request went out. So a run that comes back all-found: false is immediately diagnosable: paste one source_url into a browser. If the register shows a company, our lookup is broken, not the company. Without that field you would be guessing.

A found: false row still costs $0.01, because a definitive "not in the register" is a real answer to a KYC question. Input we could not parse costs nothing, because we never asked anyone anything.

The part that generalises ๐Ÿงญ

Two rules, both cheap:

Identifiers that look numeric usually aren't. KRS numbers, NIPs, ZIP codes, ISBNs, phone numbers, order references. If leading zeros or fixed width carry meaning, it is a string, and the cast that breaks it will happen somewhere you are not looking โ€” a CSV export, a JS JSON.parse, a Pandas read_csv that infers int64.

When an API can't distinguish "absent" from "broken", make your rows distinguish it. You cannot fix the upstream response. You can carry enough context in the output โ€” the resolved URL, an explicit found flag โ€” that a human can tell the two apart in ten seconds instead of an afternoon.

What the Actor gives you

One typed row per KRS number, up to 200 per run:

  • identity (name, legal form, KRS, NIP, REGON), address, representatives, PKD codes, share capital and bankruptcy/liquidation status โ€” flattened out of the nested envelope
  • section filter so you only pay transfer for the divisions you asked for
  • KRS numbers as strings, zero-padded, leading zeros intact
  • found: false plus the exact source_url for unknown numbers โ€” SUCCEEDED, not crashed
  • both registers: P (entrepreneurs) and S (associations and foundations)
  • retries with exponential backoff on 429/5xx, honouring Retry-After

The honest limitations ๐Ÿšง

  • OdpisAktualny is the current extract. Historical changes live in OdpisPelny (the full extract), which this Actor does not fetch.
  • Source field labels are Polish. We normalise the structure and the key names; we do not translate values.
  • The register is the Ministry's. If a filing is stale or wrong at source, it is stale or wrong here โ€” this is a register lookup, not verification.
  • No 404 means an unknown number and a changed API surface look alike. Check a source_url before believing an all-absent run.

Pricing

$0.20 per run plus $0.01 per delivered answer โ€” about $10.20 per 1,000 results. A definitive found: false counts as a delivered answer; unparseable input is free.

โ†’ Poland KRS Company Registry Scraper on Apify


Built by Devil Scrapes. We handle the leading zeros, the four-level envelopes, the Polish-language keys and the APIs with no 404, so you get a flat table instead of a weekend.

Top comments (0)