DEV Community

Bartosz Kuć
Bartosz Kuć

Posted on

Checking Polish companies from code: VAT, KRS, REGON, EU VAT (REST + Python + MCP)

If you invoice or onboard Polish companies, sooner or later you have to check two dull things that turn out to matter a lot: is this company actually a registered VAT payer, and is the bank account they gave you the one that's on the government's official white list ("Biała Lista")? Both of those affect whether you can deduct the cost and reclaim VAT, so it's not really optional.

The annoying part is that the data lives in four different places: the Ministry of Finance, the KRS court register, GUS (the stats office), and the EU's VIES service. Each one has its own API and its own quirks.

I got tired of gluing those together every time, so I wrapped them behind a few plain HTTP calls that return JSON. Full disclosure: skanfirmy.pl is mine. It's free, no key, no signup, and the web layer runs client-side with no tracking. Here's how you'd actually use it.

REST: one GET, one JSON

Cheapest thing you can do is check a NIP (the tax ID):

curl https://skanfirmy.pl/nip/5260250995
Enter fullscreen mode Exit fullscreen mode

You get back the VAT status (active, exempt, or not registered), the company details from the VAT register, and the accounts sitting on the white list.

The paths:

  • GET /nip/{nip} gives VAT status + white-list data for one NIP
  • GET /nips/{list} takes several NIPs at once (comma-separated)
  • GET /regon/{nip} returns data from the REGON register (GUS)
  • GET /vies/{country}/{number} validates an EU VAT number, e.g. /vies/DE/811128135

It's a plain GET that returns JSON, so it drops into anything that can make an HTTP request: a cron job, a lambda, a CI step, whatever.

Python

requests and a few lines. This one raises if the company isn't an active VAT payer:

import requests

def check_vat(nip: str) -> dict:
    r = requests.get(f"https://skanfirmy.pl/nip/{nip}", timeout=10)
    r.raise_for_status()
    data = r.json()
    status = data.get("vatStatus") or data.get("status")
    if status != "Czynny":  # status comes back in Polish; compare against the raw value
        raise ValueError(f"NIP {nip}: VAT status = {status!r}")
    return data

result = check_vat("5260250995")
print("White-list accounts:", result.get("accountNumbers", []))
Enter fullscreen mode Exit fullscreen mode

One thing that bit me: the status strings from the Ministry come back in Polish (Czynny = active, Zwolniony = exempt). Compare against the original string, and don't translate before you branch on it, or the check silently breaks the day someone flips the UI language.

Got a big list to run? Use GET /nips/{list}, or the /bulk page if you just need a CSV once.

MCP (for agents)

This is the part I actually built the thing for. There's an MCP server at https://skanfirmy.pl/mcp with 9 tools and no API key, so an agent (say, an accounting assistant) can run the same NIP check a human would click through.

JSON-RPC 2.0 over POST. Calling a tool:

curl -X POST https://skanfirmy.pl/mcp \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/call",
    "params": {
      "name": "sprawdz_nip",
      "arguments": { "nip": "5260250995" }
    }
  }'
Enter fullscreen mode Exit fullscreen mode

tools/list gives you all of them with their input schemas. If your agent only speaks REST, there's an llms.txt at https://skanfirmy.pl/llms.txt describing the endpoints in a model-readable way. And if you need more than the Polish registers, I keep a sister catalog of public APIs aimed at agents over at otwarteapi.pl.

When one check isn't enough

VAT status and white-list accounts change, sometimes day to day, and a one-off check won't tell you when that happens. So there's /monitoring: it diffs daily and pings you over an HMAC-signed webhook when something moves. You verify the signature on your end and react, instead of polling the registers in a loop.

That's basically it

curl for a quick look, requests to put it in code, MCP if an agent is doing the checking. All JSON, no signup, no key. (It's bilingual too: English pages live under /en/, and the REST/MCP endpoints don't care about language either way.)

If you're already solving this differently, especially the white-list account matching, I'd genuinely like to hear how. That part is fiddlier than it looks.

Top comments (0)