Guide
Exchange rates in .NET: HttpClient, System.Text.Json and IMemoryCache
A typed HttpClient, records, per-base caching and decimal instead of double.
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.