# C# and .NET with the CurrencyCore API

> Call /v1/convert and /v1/rates from .NET 8 with IHttpClientFactory, source-generated JSON records, IMemoryCache, and ProblemDetails.

Canonical: https://currency-core.com/docs/guides/csharp-dotnet/
Updated: 2026-11-11
Published: 2026-11-11

---
There is no official .NET SDK, so the typed `HttpClient` below is the supported path. `IHttpClientFactory` manages handler lifetimes and DNS refresh, unlike a manually constructed `HttpClient`. Register a typed client and set `HttpClient.Timeout` once. Keep money in `decimal`, and use source-generated `System.Text.Json` metadata for trimming and Native AOT.

The project targets `net8.0`. ASP.NET Core already references the HTTP and cache assemblies, so no package is needed. User secrets keep the key out of `appsettings.json` locally.

```json
{
  "CurrencyCore": {
    "BaseUrl": "https://api.currency-core.com/v1/",
    "ApiKey": ""
  }
}
```

Set `CurrencyCore__ApiKey` in the environment, or use `dotnet user-secrets` locally. Register the client in `Program.cs`:

```csharp
using System.Net.Http.Headers;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddMemoryCache();
builder.Services.AddProblemDetails();
builder.Services.AddHttpClient<CurrencyCoreClient>(client =>
{
    var settings = builder.Configuration.GetSection("CurrencyCore");
    client.BaseAddress = new Uri(
        settings["BaseUrl"] ?? "https://api.currency-core.com/v1/");
    client.Timeout = TimeSpan.FromSeconds(10);
    client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
        "Bearer", settings["ApiKey"]);
});
```

The records use positional constructors and explicit JSON names. This context covers all three shapes:

```csharp
using System.Text.Json.Serialization;

public sealed record ConvertResponse(
    [property: JsonPropertyName("from")] string From,
    [property: JsonPropertyName("amount")] decimal Amount,
    [property: JsonPropertyName("date")] string Date,
    [property: JsonPropertyName("requestedDate")] string? RequestedDate,
    [property: JsonPropertyName("asOf")] Dictionary<string, string>? AsOf,
    [property: JsonPropertyName("results")] List<ConvertResult> Results);

public sealed record ConvertResult(
    [property: JsonPropertyName("to")] string To,
    [property: JsonPropertyName("result")] decimal Result,
    [property: JsonPropertyName("rate")] decimal Rate);

public sealed record RatesResponse(
    [property: JsonPropertyName("base")] string Base,
    [property: JsonPropertyName("date")] string Date,
    [property: JsonPropertyName("requestedDate")] string? RequestedDate,
    [property: JsonPropertyName("asOf")] Dictionary<string, string>? AsOf,
    [property: JsonPropertyName("rates")] Dictionary<string, decimal> Rates);

public sealed record ErrorEnvelope(
    [property: JsonPropertyName("error")] ApiError? Error);

public sealed record ApiError(
    [property: JsonPropertyName("code")] string Code,
    [property: JsonPropertyName("message")] string Message);

[JsonSerializable(typeof(ConvertResponse))]
[JsonSerializable(typeof(RatesResponse))]
[JsonSerializable(typeof(ErrorEnvelope))]
internal sealed partial class CurrencyCoreJson : JsonSerializerContext { }
```

Paths are relative, because a leading slash would replace `/v1/`. Rates are cached by base and date:

```csharp
using System.Globalization;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Microsoft.Extensions.Caching.Memory;

public sealed class CurrencyCoreClient(HttpClient http, IMemoryCache cache)
{
    public Task<ConvertResponse> ConvertAsync(
        string from, string to, decimal amount, DateOnly? date, CancellationToken ct)
    {
        var path = $"convert?from={Uri.EscapeDataString(from)}" +
                   $"&to={Uri.EscapeDataString(to)}" +
                   $"&amount={amount.ToString(CultureInfo.InvariantCulture)}";
        if (date is { } d) path += $"&date={d:yyyy-MM-dd}";

        return SendAsync(path, CurrencyCoreJson.Default.ConvertResponse, ct);
    }

    public async Task<RatesResponse> RatesAsync(
        string baseCurrency, DateOnly? date, CancellationToken ct)
    {
        var day = date ?? DateOnly.FromDateTime(DateTime.UtcNow);
        var key = $"currencycore:rates:{baseCurrency}:{day:yyyy-MM-dd}";

        if (cache.TryGetValue(key, out RatesResponse? cached))
            return cached!;

        var path = $"rates/{Uri.EscapeDataString(baseCurrency)}?date={day:yyyy-MM-dd}";
        var result = await SendAsync(path, CurrencyCoreJson.Default.RatesResponse, ct);
        cache.Set(key, result, date is null ? TimeSpan.FromMinutes(5) : TimeSpan.FromDays(1));

        return result;
    }

    private async Task<T> SendAsync<T>(string path, JsonTypeInfo<T> type, CancellationToken ct)
    {
        try
        {
            using var response = await http.GetAsync(path, ct);
            if (!response.IsSuccessStatusCode)
            {
                ApiError? error = null;
                try
                {
                    error = (await response.Content.ReadFromJsonAsync(
                        CurrencyCoreJson.Default.ErrorEnvelope, ct))?.Error;
                }
                catch (JsonException)
                {
                    // A proxy or gateway can return HTML; fall back to the status.
                }

                throw new CurrencyCoreException(
                    (int)response.StatusCode,
                    error?.Code ?? "http_error",
                    error?.Message ?? $"HTTP {(int)response.StatusCode}");
            }

            return await response.Content.ReadFromJsonAsync(type, ct)
                ?? throw new InvalidOperationException("CurrencyCore returned an empty body.");
        }
        catch (OperationCanceledException) when (!ct.IsCancellationRequested)
        {
            throw new CurrencyCoreException(504, "upstream_timeout", "CurrencyCore did not respond.");
        }
    }
}

public sealed class CurrencyCoreException(int status, string code, string message)
    : Exception(message)
{
    public int Status { get; } = status;
    public string Code { get; } = code;
}
```

The controller returns `ProblemDetails` with the upstream status:

```csharp
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/currency")]
public sealed class CurrencyController(CurrencyCoreClient client) : ControllerBase
{
    [HttpGet("convert")]
    public async Task<ActionResult<ConvertResponse>> Convert(
        [FromQuery] string from = "USD",
        [FromQuery] string to = "INR",
        [FromQuery] decimal amount = 100,
        [FromQuery] DateOnly? date = null,
        CancellationToken ct = default)
    {
        try
        {
            return Ok(await client.ConvertAsync(from, to, amount, date, ct));
        }
        catch (CurrencyCoreException ex)
        {
            return Problem(statusCode: ex.Status, title: ex.Code, detail: ex.Message);
        }
    }
}
```

A conversion returns one entry per target; rates include the base at `1`:

```json
{
  "from": "USD",
  "amount": 100,
  "date": "2026-11-10",
  "results": [{ "to": "INR", "result": 8345.0, "rate": 83.45 }]
}
```

```json
{ "base": "EUR", "date": "2026-11-10", "rates": { "EUR": 1, "USD": 1.0861 } }
```

A free organization gets 1,000 requests per month at 60 per minute. Test keys draw on the same data and quota; live keys need a paid plan.

## Production notes

`HttpClient.Timeout` is one deadline for the call. For a shorter budget, pass a `CancellationToken`; the `when (!ct.IsCancellationRequested)` filter separates that from an upstream timeout. `SocketsHttpHandler.ConnectTimeout` sets a distinct connect deadline.

`IHttpClientFactory` pools and rotates handlers. Do not create `HttpClient` instances yourself or store them in fields; let the factory supply the typed client.

`IMemoryCache` is per process; use a distributed cache across instances. A date-keyed historical entry can live a day or longer; the latest snapshot should expire in minutes.

There are no `ETag`, `Last-Modified`, or quota headers, so conditional requests do nothing. Log the response date, count billable requests, and honor `Retry-After` on `429`. The feed is central-bank reference data with daily fixings, polled hourly, not an executable quote.

## Next steps

- [API reference](/docs/api/) and [authentication](/docs/authentication/)
- [Error codes](/docs/errors/) and [all guides](/docs/guides/)
- [Caching guide](/docs/guides/caching/)
