Guide

Exchange rates in .NET: HttpClient, System.Text.Json and IMemoryCache

A typed HttpClient, records, per-base caching and decimal instead of double.

Intermediate9 min read

What this covers

The .NET version of a currency integration has three decisions that matter: how the client is registered (so sockets are not exhausted), how the JSON is mapped (the payload is snake_case, the records are not), and whether money is a double or a decimal.

This guide makes all three the right way round, with a cache keyed per base currency so a request for EUR and a request for USD do not overwrite each other.

Register one typed client

AddHttpClient gives you a pooled handler and a place to set the base address and timeout once. Constructing a new HttpClient per call is the classic way to exhaust sockets under load.

Program.cs

builder.Services.AddHttpClient<RatesClient>(http =>
{
    http.BaseAddress = new Uri("https://fiatagent.base44.app");
    http.Timeout = TimeSpan.FromSeconds(5);
});

builder.Services.AddMemoryCache();

Map snake_case into records

System.Text.Json matches property names exactly by default, so base_currency needs an explicit JsonPropertyName. Rates are decoded as decimal, not double — the payload carries six or more decimals and money should not be binary.

RatesClient.cs

public sealed record Envelope(Meta Meta, Payload Data);

public sealed record Meta(
    [property: JsonPropertyName("base_currency")] string BaseCurrency,
    [property: JsonPropertyName("currency_count")] int CurrencyCount,
    [property: JsonPropertyName("updated_at")] DateTimeOffset UpdatedAt);

public sealed record Payload([property: JsonPropertyName("latest")] Latest Latest);

public sealed record Latest(
    [property: JsonPropertyName("rates")] IReadOnlyDictionary<string, decimal> Rates);

public sealed class RatesClient(HttpClient http, IConfiguration config)
{
    public async Task<Envelope> GetAsync(string baseCurrency = "USD", CancellationToken ct = default)
    {
        using var request = new HttpRequestMessage(
            HttpMethod.Get, $"/api/v1/rates?base={Uri.EscapeDataString(baseCurrency)}");

        request.Headers.Authorization =
            new AuthenticationHeaderValue("Bearer", config["FiatAgent:Key"]);

        using var response = await http.SendAsync(request, ct);
        response.EnsureSuccessStatusCode();

        return await response.Content.ReadFromJsonAsync<Envelope>(ct)
               ?? throw new InvalidOperationException("Fiat Agent returned an empty body");
    }
}

Cache per base currency

The cache key includes the base, because the quote map changes with it — one entry for every base you display. Fifteen minutes suits a price on a page; the reading timestamp is in the response if you need to show how current the figure is.

RatesCache.cs

public sealed class RatesCache(RatesClient client, IMemoryCache cache)
{
    public Task<Envelope> GetAsync(string baseCurrency = "USD", CancellationToken ct = default) =>
        cache.GetOrCreateAsync($"fiat-agent:rates:{baseCurrency}", entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15);
            return client.GetAsync(baseCurrency, ct);
        })!;
}

Convert in decimal and handle the three failures

One reading gives you every pair: both quotes share a base, so the cross rate is a single division. Round once, at the end, to the target currency's own number of decimals.

EnsureSuccessStatusCode throws an HttpRequestException carrying the status, which is enough to tell a bad request from a dead credential from a transient store read.

Convert.cs

static decimal Convert(decimal amount, decimal rate, int decimals) =>
    Math.Round(amount * rate, decimals, MidpointRounding.ToEven);

var rates = envelope.Data.Latest.Rates;
var gbpPerEur = rates["GBP"] / rates["EUR"];

Console.WriteLine(Convert(49.99m, gbpPerEur, 2)); // 43.12

try
{
    return await client.GetAsync("EUR", ct);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.ServiceUnavailable)
{
    await Task.Delay(TimeSpan.FromSeconds(2), ct);
    return await client.GetAsync("EUR", ct);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized)
{
    throw new InvalidOperationException("Key missing, wrong or revoked.", ex);
}

Common questions

Should rates be double or decimal in .NET?

decimal. The payload is high-precision and money arithmetic should not be binary — the only place a double is acceptable is a chart axis, never a price or an invoice line.

How do I read history from .NET?

Append ?from= and ?to= as UTC dates to the keyed request and add a snapshots array to the envelope records. Readings arrive newest first, each with its own fetched_at.

Do I need IMemoryCache?

Any shared cache works. What matters is that one reading serves every request: without a cache, a server-side call per page view spends the 5,000-request signup bonus on your own traffic.

Run it against the real thing.

The open endpoint needs no key, so you can paste these samples into a terminal before you sign up for anything.