Gå til hovedindhold
FirmaAPIFirmaAPI

Guide

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

En kort guide til at slå danske virksomheder op fra .NET med HttpClient og System.Text.Json. Du får en typet klient med timeout, genforsøg og fejlkoder.

Det skal du bruge

Du skal bruge .NET 8 eller nyere og en API-nøgle. Opret en gratis konto for at få 1.000 opslag om måneden. Gem nøglen i en miljøvariabel, User Secrets eller din hemmelighedsstyring, og ikke i kildekoden:

dotnet user-secrets set "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, der starter med cvr_test_, giver syntetiske data uden at bruge af kvoten.

En typet klient med genforsøg

Klienten består af tre records til svarene, en fejlklasse og selve klienten. Den prøver igen ved midlertidige fejl og kaster en FirmaApiException med status og kode. Metoden ErGyldigtCvr tjekker modulus-11, så du ikke bruger opslag på tastefejl.

FirmaApiClient.cs

using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;

public sealed record Virksomhed(
    [property: JsonPropertyName("cvr")] string Cvr,
    [property: JsonPropertyName("name")] string Name,
    [property: JsonPropertyName("status")] string Status,
    [property: JsonPropertyName("address")] string? Address,
    [property: JsonPropertyName("zipcode")] string? Zipcode,
    [property: JsonPropertyName("city")] string? City,
    [property: JsonPropertyName("industry_text")] string? IndustryText,
    [property: JsonPropertyName("employees")] string? Employees);

public sealed record SoegeSvar(
    [property: JsonPropertyName("results")] List<Virksomhed> Results,
    [property: JsonPropertyName("total")] int Total);

public sealed record BatchSvar(
    [property: JsonPropertyName("results")] List<Virksomhed> Results,
    [property: JsonPropertyName("errors")] List<JsonElement> Errors,
    [property: JsonPropertyName("quota_used")] int QuotaUsed);

public sealed class FirmaApiException : Exception
{
    public HttpStatusCode Status { get; }
    public string Code { get; }

    public FirmaApiException(HttpStatusCode status, string code, string message)
        : base($"{(int)status} {code}: {message}")
    {
        Status = status;
        Code = code;
    }
}

public sealed class FirmaApiClient
{
    private const int MaxRetries = 3;
    private readonly HttpClient _http;

    // HttpClient skal være sat op med BaseAddress, timeout og Authorization-header.
    public FirmaApiClient(HttpClient http) => _http = http;

    public static bool ErGyldigtCvr(string input)
    {
        var cifre = new string(input.Where(char.IsDigit).ToArray());
        if (cifre.Length != 8) return false;
        int[] vaegte = [2, 7, 6, 5, 4, 3, 2, 1];
        var sum = cifre.Select((c, i) => (c - '0') * vaegte[i]).Sum();
        return sum % 11 == 0;
    }

    public Task<Virksomhed> HentVirksomhedAsync(string cvr, string? include = null, CancellationToken ct = default)
    {
        var sti = "company/" + Uri.EscapeDataString(cvr);
        if (include is not null) sti += "?include=" + Uri.EscapeDataString(include);
        return SendAsync<Virksomhed>(HttpMethod.Get, sti, null, ct);
    }

    public Task<SoegeSvar> SoegAsync(string q, int limit = 10, CancellationToken ct = default) =>
        SendAsync<SoegeSvar>(HttpMethod.Get,
            "company/search?q=" + Uri.EscapeDataString(q) + "&limit=" + limit, null, ct);

    public Task<BatchSvar> BatchAsync(IEnumerable<string> cvrNumre, CancellationToken ct = default) =>
        SendAsync<BatchSvar>(HttpMethod.Post, "company/batch", new { cvr_numbers = cvrNumre }, ct);

    private async Task<T> SendAsync<T>(HttpMethod method, string sti, object? body, CancellationToken ct)
    {
        for (var forsoeg = 0; ; forsoeg++)
        {
            using var request = new HttpRequestMessage(method, sti);
            if (body is not null)
            {
                request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
            }

            using var response = await _http.SendAsync(request, ct);
            var tekst = await response.Content.ReadAsStringAsync(ct);

            if (response.IsSuccessStatusCode)
            {
                return JsonSerializer.Deserialize<T>(tekst)!;
            }

            var (kode, besked) = LaesFejl(tekst);
            var kanProevesIgen =
                (response.StatusCode == HttpStatusCode.TooManyRequests && kode == "RATE_LIMIT_EXCEEDED")
                || response.StatusCode is HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable;

            if (kanProevesIgen && forsoeg < MaxRetries)
            {
                var ventetid = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(Math.Pow(2, forsoeg));
                await Task.Delay(ventetid, ct);
                continue;
            }

            throw new FirmaApiException(response.StatusCode, kode, besked);
        }
    }

    private static (string Kode, string Besked) LaesFejl(string tekst)
    {
        try
        {
            using var doc = JsonDocument.Parse(tekst);
            if (doc.RootElement.TryGetProperty("error", out var e))
            {
                var kode = e.TryGetProperty("code", out var c) ? c.GetString() ?? "UNKNOWN" : "UNKNOWN";
                var besked = e.TryGetProperty("message", out var m) ? m.GetString() ?? "" : "";
                return (kode, besked);
            }
        }
        catch (JsonException) { }
        return ("UNKNOWN", tekst.Length > 200 ? tekst[..200] : tekst);
    }
}

Klienten forventer, at HttpClient er sat op med BaseAddress, en timeout og Authorization-headeren. Genforsøg sker ved RATE_LIMIT_EXCEEDED, 502 og 503 og følger Retry-After, hvis headeren er sat. Ved MONTHLY_LIMIT_EXCEEDED rejses fejlen med det samme.

Slå en virksomhed op

using System.Net.Http.Headers;

using var http = new HttpClient
{
    BaseAddress = new Uri("https://firmaapi.dk/api/v1/"),
    Timeout = TimeSpan.FromSeconds(10),
};
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("FIRMAAPI_KEY"));
var api = new FirmaApiClient(http);

var cvr = "24256790";
if (!FirmaApiClient.ErGyldigtCvr(cvr))
{
    Console.WriteLine("Ugyldigt CVR-nummer");
    return;
}

try
{
    var v = await api.HentVirksomhedAsync(cvr);
    Console.WriteLine($"{v.Name} ({v.Status}), {v.City}");
}
catch (FirmaApiException ex) when (ex.Code == "NOT_FOUND")
{
    Console.WriteLine("CVR-nummeret findes ikke");
}
catch (FirmaApiException ex) when (ex.Code == "MONTHLY_LIMIT_EXCEEDED")
{
    Console.WriteLine("Månedskvoten er brugt");
}

Recorden Virksomhed indeholder de mest brugte felter. API'et returnerer flere, og dem, du ikke har med i recorden, ignoreres ved deserialisering. Felter kan være null, derfor er de markeret med spørgsmålstegn. Skal du bruge flere felter, tilføjer du dem til recorden med et JsonPropertyName, der svarer til navnet i JSON. Se alle felter i dokumentationen.

Søg og batch

Navnesøgningen returnerer results og total. Batch kræver Basis og tager 100 CVR-numre pr. kald på Basis, 200 på Standard og 500 på Professionel. Hvert fundet CVR-nummer tæller som ét opslag. Numre, der ikke findes, ligger i errors og tæller ikke.

// Find CVR-nummer ud fra et navn
var svar = await api.SoegAsync("novo nordisk", limit: 5);
foreach (var hit in svar.Results)
{
    Console.WriteLine($"{hit.Cvr} {hit.Name} {hit.City}");
}

// Batch-opslag (Basis eller højere), op til 100 pr. kald på Basis
var batch = await api.BatchAsync(new[] { "24256790", "54562519", "25313763" });
Console.WriteLine($"{batch.Results.Count} fundet, {batch.Errors.Count} fejl, {batch.QuotaUsed} opslag brugt");

Brug klienten i ASP.NET Core

I en webapplikation registrerer du klienten som en typet HttpClient, så IHttpClientFactory styrer levetiden. Eksemplet sætter base-adresse, timeout og Bearer-header i samme blok og læser nøglen fra konfigurationen (User Secrets lokalt og miljøvariabler i drift).

// Program.cs i ASP.NET Core
builder.Services.AddHttpClient<FirmaApiClient>(client =>
{
    client.BaseAddress = new Uri("https://firmaapi.dk/api/v1/");
    client.Timeout = TimeSpan.FromSeconds(10);
    client.DefaultRequestHeaders.Authorization =
        new AuthenticationHeaderValue("Bearer", builder.Configuration["FirmaApi:Key"]);
});

// Brug klienten ved at injicere FirmaApiClient i en controller eller en minimal API-rute.

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
  • Send et CancellationToken med, så opslag kan afbrydes sammen med requesten.
  • Cache svar i IMemoryCache eller en distribueret cache, og slå kun op igen, når data skal være friske.
  • Brug webhooks, hvis du vil have besked om ændringer i stedet for at slå op igen.

Se priserne for kvoter og rate limits.

Ofte stillede spørgsmål

Hvilken .NET-version kræver eksemplerne?

NET 8 eller nyere. Koden bruger records, top-level statements og System.Text.Json, som alle er en del af frameworket, så du behøver ingen NuGet-pakker.

Skal jeg oprette HttpClient selv?

I en konsolapplikation kan du oprette én HttpClient og genbruge den. I ASP.NET Core registrerer du klienten med AddHttpClient, så IHttpClientFactory styrer forbindelserne. Opret ikke en ny HttpClient for hvert opslag.

Hvorfor er employees en streng?

Feltet er en streng, fordi CVR angiver antal ansatte som intervaller (fx 10-19) eller som et tal, afhængigt af virksomheden. Parse det til et heltal, hvis du har brug for tallet, og håndter intervaller, hvis du får dem.

Hvordan tester jeg uden at bruge af kvoten?

Brug en nøgle, der starter med cvr_test_. Så får du syntetiske data, og opslagene tæller ikke med i kvoten. Det passer godt til enhedstests og integrationstests i dit CI-miljø.

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.