Gå til hovedindhold
FirmaAPIFirmaAPI

Guide

CVR API i Python: opslag, søgning og fejlhåndtering

En kort guide til at slå danske virksomheder op fra Python med requests. Du får en lille klient med timeout, genforsøg og fejlkoder, som du kan bruge direkte i dit projekt.

Det skal du bruge

Du skal bruge en API-nøgle, Python 3.10 eller nyere og pakken requests. Du kan oprette en nøgle gratis og få 1.000 opslag om måneden. Gem nøglen i en miljøvariabel.

pip install requests
export FIRMAAPI_KEY="cvr_live_din_api_nøgle"

Alle kald går til https://firmaapi.dk/api/v1, og nøglen sendes i headeren Authorization: Bearer cvr_live_.... Vil du først teste uden at bruge af kvoten, bruger du nøglens separate test-nøgle, der starter med cvr_test_.

En lille klient med genforsøg

Klienten herunder samler det, du har brug for i alle kald: en Session, der genbruger forbindelsen, en timeout, genforsøg ved 429 og 502/503 og en fejlklasse med kode og besked. Funktionen er_gyldigt_cvr tjekker modulus-11, så du ikke bruger opslag på CVR-numre med tastefejl.

firmaapi.py

import os
import time

import requests

BASE_URL = "https://firmaapi.dk/api/v1"


class FirmaApiError(Exception):
    """Fejl fra API'et. status er HTTP-statussen, code er fx NOT_FOUND."""

    def __init__(self, status: int, code: str, message: str):
        super().__init__(f"{status} {code}: {message}")
        self.status = status
        self.code = code
        self.message = message


def er_gyldigt_cvr(cvr: str) -> bool:
    """Tjekker format og modulus 11, så du sparer opslag på tastefejl."""
    cifre = "".join(ch for ch in cvr if ch.isdigit())
    if len(cifre) != 8:
        return False
    vaegte = [2, 7, 6, 5, 4, 3, 2, 1]
    return sum(int(c) * w for c, w in zip(cifre, vaegte)) % 11 == 0


class FirmaApi:
    def __init__(self, api_key: str | None = None, timeout: float = 10, max_retries: int = 3):
        self.session = requests.Session()
        self.session.headers["Authorization"] = f"Bearer {api_key or os.environ['FIRMAAPI_KEY']}"
        self.timeout = timeout
        self.max_retries = max_retries

    def _fejl(self, res: requests.Response) -> dict:
        try:
            return res.json().get("error", {})
        except ValueError:
            return {}

    def _request(self, method: str, path: str, **kwargs) -> dict:
        for forsoeg in range(self.max_retries + 1):
            res = self.session.request(method, BASE_URL + path, timeout=self.timeout, **kwargs)
            if res.ok:
                return res.json()

            fejl = self._fejl(res)
            kode = fejl.get("code", "UNKNOWN")
            kan_proeves_igen = (
                (res.status_code == 429 and kode == "RATE_LIMIT_EXCEEDED")
                or res.status_code in (502, 503)
            )
            if kan_proeves_igen and forsoeg < self.max_retries:
                ventetid = float(res.headers.get("Retry-After", 2 ** forsoeg))
                time.sleep(ventetid)
                continue

            raise FirmaApiError(res.status_code, kode, fejl.get("message", res.text[:200]))
        raise RuntimeError("Uventet tilstand")

    def virksomhed(self, cvr: str, include: str | None = None) -> dict:
        params = {"include": include} if include else None
        return self._request("GET", f"/company/{cvr}", params=params)

    def soeg(self, q: str, status: str | None = "Aktiv", limit: int = 10) -> dict:
        params = {"q": q, "limit": limit}
        if status:
            params["status"] = status
        return self._request("GET", "/company/search", params=params)

    def avanceret_soegning(self, **filtre) -> dict:
        return self._request("GET", "/search/advanced", params=filtre)

    def batch(self, cvr_numre: list[str]) -> dict:
        return self._request("POST", "/company/batch", json={"cvr_numbers": cvr_numre})

Genforsøg gælder kun fejl, der kan forsvinde af sig selv. Ved RATE_LIMIT_EXCEEDED venter koden det antal sekunder, headeren Retry-After angiver. Ved månedskvoten, MONTHLY_LIMIT_EXCEEDED, giver det ingen mening at prøve igen, så fejlen rejses med det samme.

Slå en virksomhed op

api = FirmaApi()

cvr = "24256790"
if not er_gyldigt_cvr(cvr):
    raise SystemExit("Ugyldigt CVR-nummer")

try:
    v = api.virksomhed(cvr)
    print(v["name"], v["status"], v["city"])
except FirmaApiError as e:
    if e.code == "NOT_FOUND":
        print("CVR-nummeret findes ikke")
    elif e.code == "MONTHLY_LIMIT_EXCEEDED":
        print("Månedskvoten er brugt")
    else:
        raise

Svaret er et almindeligt dict med felter som cvr, name, status, address, zipcode, city, industry_text og employees. Felter kan være None, når oplysningen ikke er registreret, så brug .get() til valgfrie felter. Tunge blokke som historik og regnskaber hentes med include, fx api.virksomhed(cvr, include="history,finance"), hvis din plan giver adgang.

Søg og filtrér

Navnesøgningen returnerer et objekt med results og total. Den avancerede søgning har mere end 30 filtre og returnerer results, limit, offset og has_more. Brug offset til at hente næste side, mens has_more er sand.

# Find CVR-nummer ud fra et navn
svar = api.soeg("novo nordisk", limit=5)
for hit in svar["results"]:
    print(hit["cvr"], hit["name"], hit["city"])

# Avanceret søgning: aktive ApS'er i København med mindst 10 ansatte
side = api.avanceret_soegning(
    company_type="ApS",
    municipality="København",
    employees_min=10,
    status="Aktiv",
    limit=100,
    offset=0,
)
print(len(side["results"]), side["has_more"])

Mange CVR-numre ad gangen

Batch kræver Basis og tager op til 100 CVR-numre pr. kald på Basis, 200 på Standard og 500 på Professionel. Hvert fundet CVR-nummer tæller som ét opslag. Dubletter, ugyldigt formaterede numre og CVR-numre, der ikke findes, tæller ikke. Numre, der ikke findes, kommer i listen errors.

def hent_mange(api: FirmaApi, cvr_numre: list[str], blok: int = 100) -> dict[str, dict]:
    """Slår CVR-numre op i blokke. Basis tillader 100 pr. kald, Professionel 500."""
    fundet: dict[str, dict] = {}
    gyldige = [c for c in dict.fromkeys(cvr_numre) if er_gyldigt_cvr(c)]
    for i in range(0, len(gyldige), blok):
        svar = api.batch(gyldige[i : i + blok])
        for v in svar["results"]:
            fundet[v["cvr"]] = v
        # svar["errors"] indeholder numre, der ikke blev fundet
    return fundet

Fejl, du bør håndtere

StatusKodeHvad du gør
400INVALID_CVRValider input, før du kalder
401INVALID_API_KEYTjek nøglen i miljøvariablen
403PLAN_UPGRADE_REQUIREDPlanen mangler adgang. Se required_plan i svaret
404NOT_FOUNDVirksomheden findes ikke
429RATE_LIMIT_EXCEEDEDVent Retry-After sekunder (håndteres af klienten)
429MONTHLY_LIMIT_EXCEEDEDKvoten er brugt
502/503ERST_ERROR / MAINTENANCEPrøv igen lidt senere
Gem svar, du kan genbruge, i din egen database eller cache, og slå kun op igen, når du har brug for friske data. Det sparer opslag og gør dit program hurtigere. Når du vil have besked om ændringer, kan du bruge webhooks i stedet for at slå op igen. Se priser for kvoter.
  • Sæt altid en timeout, så en langsom forbindelse ikke blokerer din applikation.
  • Log HTTP-status og fejlkode, men aldrig API-nøglen.
  • Brug sandbox-nøglen i automatiske tests, så de ikke bruger af kvoten.

Ofte stillede spørgsmål

Hvilken Python-version kræver eksemplerne?

Python 3.10 eller nyere og pakken requests. Koden bruger type hints og f-strings, men ellers ingen særlige biblioteker.

Skal jeg bruge async?

Det er ikke nødvendigt. Med requests og en Session kan du håndtere flere hundrede opslag i minuttet inden for din plans rate limit. Har du mange CVR-numre, er batch-endpointet bedre end at parallelisere enkeltopslag.

Hvordan opbevarer jeg API-nøglen?

I en miljøvariabel, fx FIRMAAPI_KEY, eller i din hemmelighedsstyring. Skriv aldrig nøglen ind i koden, og kald ikke API'et fra klientkode i en browser.

Hvad gør jeg, når jeg får 429?

Der er to slags. RATE_LIMIT_EXCEEDED betyder, at du sender for hurtigt, og her venter du Retry-After sekunder. MONTHLY_LIMIT_EXCEEDED betyder, at månedskvoten er brugt, og her hjælper kun at vente til næste måned eller opgradere.

Prøv det med din egen API-nøgle

Opret en gratis konto, og hent din API-nøgle med det samme. Intet kreditkort kræves.