Code Examples

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.


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.

{
  "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:

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:

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:

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:

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:

{
  "from": "USD",
  "amount": 100,
  "date": "2026-11-10",
  "results": [{ "to": "INR", "result": 8345.0, "rate": 83.45 }]
}
{ "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