Gå til hovedindhold
FirmaAPIFirmaAPI

Guide

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

En kort guide til at slå danske virksomheder op fra PHP med cURL. Koden kræver ingen pakker og håndterer timeout, genforsøg og fejlkoder.

Det skal du bruge

Du skal bruge PHP 8.1 eller nyere med cURL og en API-nøgle. Den gratis plan giver 1.000 opslag om måneden. Læg nøglen i en miljøvariabel eller en konfigurationsfil uden for webroden:

export FIRMAAPI_KEY="cvr_live_din_api_nøgle"

Alle kald går til https://firmaapi.dk/api/v1 med headeren Authorization: Bearer .... En nøgle med præfikset cvr_test_ giver syntetiske data uden at bruge af kvoten, så den er god til tests.

En klient i én fil

Gem koden som firmaapi.php. Klienten sætter en timeout, læser Retry-After fra svarheaderne, prøver igen ved midlertidige fejl og kaster en FirmaApiException med HTTP-status og fejlkode. Funktionen erGyldigtCvr tjekker modulus-11, så du ikke bruger opslag på tastefejl.

firmaapi.php

<?php
declare(strict_types=1);

final class FirmaApiException extends RuntimeException
{
    public function __construct(
        public readonly int $status,
        public readonly string $errorCode,
        string $message,
    ) {
        parent::__construct($status . ' ' . $errorCode . ': ' . $message);
    }
}

function erGyldigtCvr(string $input): bool
{
    $cifre = preg_replace('/\D/', '', $input);
    if (strlen($cifre) !== 8) {
        return false;
    }
    $vaegte = [2, 7, 6, 5, 4, 3, 2, 1];
    $sum = 0;
    foreach (str_split($cifre) as $i => $ciffer) {
        $sum += (int) $ciffer * $vaegte[$i];
    }
    return $sum % 11 === 0;
}

final class FirmaApi
{
    private const BASE_URL = 'https://firmaapi.dk/api/v1';

    public function __construct(
        private string $apiKey,
        private int $maxRetries = 3,
        private int $timeout = 10,
    ) {
    }

    public function request(string $method, string $path, array $query = [], ?array $body = null): array
    {
        $query = array_filter($query, static fn ($v) => $v !== null);
        $url = self::BASE_URL . $path . ($query ? '?' . http_build_query($query) : '');

        for ($forsoeg = 0; ; $forsoeg++) {
            $retryAfter = null;
            $headers = ['Authorization: Bearer ' . $this->apiKey, 'Accept: application/json'];

            $ch = curl_init($url);
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => $method,
                CURLOPT_TIMEOUT => $this->timeout,
                CURLOPT_HEADERFUNCTION => function ($handle, string $line) use (&$retryAfter): int {
                    if (stripos($line, 'Retry-After:') === 0) {
                        $retryAfter = (int) trim(substr($line, 12));
                    }
                    return strlen($line);
                },
            ]);
            if ($body !== null) {
                $headers[] = 'Content-Type: application/json';
                curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_THROW_ON_ERROR));
            }
            curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);

            $raw = curl_exec($ch);
            if ($raw === false) {
                $fejl = curl_error($ch);
                if ($forsoeg < $this->maxRetries) {
                    sleep(2 ** $forsoeg);
                    continue;
                }
                throw new RuntimeException('Netværksfejl: ' . $fejl);
            }

            $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
            $data = json_decode((string) $raw, true);
            $data = is_array($data) ? $data : [];

            if ($status >= 200 && $status < 300) {
                return $data;
            }

            $fejl = $data['error'] ?? [];
            $kode = $fejl['code'] ?? 'UNKNOWN';
            $kanProevesIgen = ($status === 429 && $kode === 'RATE_LIMIT_EXCEEDED')
                || in_array($status, [502, 503], true);

            if ($kanProevesIgen && $forsoeg < $this->maxRetries) {
                sleep(max(1, $retryAfter ?? 2 ** $forsoeg));
                continue;
            }
            throw new FirmaApiException($status, $kode, $fejl['message'] ?? 'Ukendt fejl');
        }
    }

    public function virksomhed(string $cvr, ?string $include = null): array
    {
        return $this->request('GET', '/company/' . rawurlencode($cvr), ['include' => $include]);
    }

    public function soeg(string $q, string $status = 'Aktiv', int $limit = 10): array
    {
        return $this->request('GET', '/company/search', ['q' => $q, 'status' => $status, 'limit' => $limit]);
    }

    public function avanceretSoegning(array $filtre): array
    {
        return $this->request('GET', '/search/advanced', $filtre);
    }

    public function batch(array $cvrNumre): array
    {
        return $this->request('POST', '/company/batch', [], ['cvr_numbers' => array_values($cvrNumre)]);
    }
}

Genforsøg sker kun ved RATE_LIMIT_EXCEEDED, 502, 503 og netværksfejl. Er månedskvoten brugt (MONTHLY_LIMIT_EXCEEDED), rejses fejlen straks.

Slå en virksomhed op

<?php
require __DIR__ . '/firmaapi.php';

$api = new FirmaApi(getenv('FIRMAAPI_KEY') ?: '');
$cvr = '24256790';

if (!erGyldigtCvr($cvr)) {
    exit("Ugyldigt CVR-nummer\n");
}

try {
    $v = $api->virksomhed($cvr);
    echo $v['name'] . ' (' . $v['status'] . '), ' . ($v['city'] ?? '') . "\n";
} catch (FirmaApiException $e) {
    match ($e->errorCode) {
        'NOT_FOUND' => print("CVR-nummeret findes ikke\n"),
        'MONTHLY_LIMIT_EXCEEDED' => print("Månedskvoten er brugt\n"),
        default => throw $e,
    };
}

Svaret er et array med blandt andet cvr, name, status, address, zipcode, city, industry_text og employees. Felter kan være null, så brug ?? til valgfrie felter. Vil du have historik eller regnskaber med, kalder du $api->virksomhed($cvr, 'history,finance'), hvis planen tillader det.

Søg og bladr

Navnesøgningen returnerer results og total. Den avancerede søgning har over 30 filtre og returnerer has_more, så du kan hente flere sider med offset.

// Find CVR-nummer ud fra et navn
$svar = $api->soeg('novo nordisk', 'Aktiv', 5);
foreach ($svar['results'] as $hit) {
    echo $hit['cvr'] . ' ' . $hit['name'] . "\n";
}

// Avanceret søgning med paginering
$offset = 0;
do {
    $side = $api->avanceretSoegning([
        'industry' => '62',
        'city' => 'Aarhus',
        'status' => 'Aktiv',
        'limit' => 100,
        'offset' => $offset,
    ]);
    foreach ($side['results'] as $v) {
        echo $v['cvr'] . ' ' . $v['name'] . "\n";
    }
    $offset += 100;
} while ($side['has_more']);

Batch-opslag

Batch kræver Basis. Grænsen er 100 CVR-numre pr. kald på Basis, 200 på Standard og 500 på Professionel. Hvert fundet CVR-nummer tæller som ét opslag, mens dubletter og numre, der ikke findes, ikke tæller.

function hentMange(FirmaApi $api, array $cvrNumre, int $blok = 100): array
{
    $gyldige = array_values(array_filter(array_unique($cvrNumre), 'erGyldigtCvr'));
    $fundet = [];

    foreach (array_chunk($gyldige, $blok) as $gruppe) {
        $svar = $api->batch($gruppe);
        foreach ($svar['results'] as $v) {
            $fundet[$v['cvr']] = $v;
        }
        // $svar['errors'] indeholder numre, der ikke blev fundet
    }
    return $fundet;
}

Gem svar, så du sparer opslag

I en webapplikation vil du ikke slå den samme virksomhed op ved hver sidevisning. En simpel filcache er nok til at komme i gang. I et rigtigt system bruger du Redis, APCu eller din frameworks cache.

function virksomhedMedCache(FirmaApi $api, string $cvr, int $ttl = 86400): array
{
    $fil = sys_get_temp_dir() . '/cvr_' . $cvr . '.json';
    if (is_file($fil) && time() - filemtime($fil) < $ttl) {
        return json_decode(file_get_contents($fil), true);
    }
    $data = $api->virksomhed($cvr);
    file_put_contents($fil, json_encode($data), LOCK_EX);
    return $data;
}

Fejl, du bør håndtere

StatusKodeHvad du gør
400INVALID_CVRValider input, før du kalder
401INVALID_API_KEYTjek nøglen i konfigurationen
403PLAN_UPGRADE_REQUIREDPlanen mangler adgang. Se required_plan
404NOT_FOUNDVirksomheden findes ikke
429RATE_LIMIT_EXCEEDEDVent Retry-After sekunder (håndteres af klienten)
429MONTHLY_LIMIT_EXCEEDEDKvoten er brugt
  • Skriv aldrig API-nøglen i fejllogs eller i svar til brugeren.
  • Kald API'et fra serveren og ikke fra JavaScript i browseren.
  • Brug webhooks, hvis du vil have besked om ændringer i stedet for at slå op igen.
Se priserne for kvoter og rate limits på hver plan.

Ofte stillede spørgsmål

Hvilken PHP-version kræver eksemplerne?

PHP 8.1 eller nyere med cURL-udvidelsen, som er slået til i de fleste installationer. Koden bruger constructor promotion og readonly-egenskaber og kræver ingen Composer-pakker.

Kan jeg bruge file_get_contents i stedet for cURL?

Det kan lade sig gøre med en stream context, men cURL giver dig statuskode, headere og timeout på en enklere måde. Vi anbefaler cURL, især fordi du skal læse Retry-After-headeren ved 429.

Hvordan bruger jeg det i WordPress eller et andet CMS?

Brug samme klient fra et plugin eller en tema-funktion, og gem svar i en transient eller en cache, så du ikke slår den samme virksomhed op ved hvert sidevisning. Læg nøglen i en konstant i wp-config.php eller en miljøvariabel og ikke i databasen eller i tema-filer.

Hvad gør jeg, når API'et svarer 403?

Det betyder oftest, at din plan ikke giver adgang til det, du beder om. Svaret har fejlkoden PLAN_UPGRADE_REQUIRED og felterne current_plan og required_plan. Brug dem til at vise en tydelig besked, eller fjern den udvidede include-blok fra kaldet.

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.