Späť na blog

Currency Exchange Rate API in Rust — Complete Integration Tutorial

V
Vlado Grigirov
August 04, 2026
Rust Currency API Exchange Rates Tutorial reqwest Tokio Fintech

If you are building fintech infrastructure, a pricing engine, a CLI tool, or a high-throughput backend, sooner or later you will need live foreign exchange data. Wiring up a currency exchange rate API in Rust is one of the cleanest networking tasks the language has to offer: with reqwest, tokio, and serde you get an async HTTP client, automatic JSON deserialization, and compile-time guarantees that your rate-handling code is correct before it ever runs. This tutorial walks the entire path — from your first GET request to a production-shaped currency converter with a reusable client, typed models, custom error handling, decimal-safe money math, and an in-memory cache that keeps you inside the free tier.

By the end you will have a small, reusable Rust module built on the Finexly API documentation that converts between any of 170+ currencies with real-time rates. If you have already read our Go currency API tutorial, this is the Rust counterpart with the same architecture — a typed client, a cache, and clean error propagation.

Why Rust Is a Great Fit for Currency Data

Currency data is I/O bound, latency sensitive, and — in any system that touches money — correctness critical. Rust is unusually well suited to all three:

  • Async by default. reqwest is built on hyper and tokio, so concurrent rate fetches are cheap and non-blocking. You can request dozens of currency pairs in parallel without spawning OS threads.
  • The type system catches mistakes early. A serde-derived struct means a malformed response or a missing field is a compile-time or deserialize-time error, not a mysterious null three layers deep in production.
  • No garbage collector, predictable latency. For a service quoting FX rates under load, the absence of GC pauses matters.
  • Decimal-safe money. With the rust_decimal crate you get fixed-point arithmetic, so you never ship a rounding bug that turns €100.00 into €99.999999.

The tradeoff is a slightly steeper setup than a scripting language, but the result is a currency client you can drop into a service and trust.

What You Will Build

A reusable FinexlyClient that:

  1. Fetches the latest rates for a base currency from a currency exchange rate API.
  2. Deserializes the JSON into typed Rust structs with serde.
  3. Converts between any two currencies, even when neither is the base (cross rates).
  4. Handles errors — network failures, non-200 responses, rate limits — with a custom error type.
  5. Caches responses in memory with a TTL so you stay well inside a free plan.

Prerequisites and Project Setup

You need a recent stable Rust toolchain (install via rustup) and a free Finexly API key. You can sign up for free in under a minute — no credit card required, 1,000 requests per month on the free tier.

Create a new project and add the dependencies:

cargo new finexly-rates
cd finexly-rates
cargo add tokio --features full
cargo add reqwest --features json
cargo add serde --features derive
cargo add thiserror
cargo add rust_decimal rust_decimal_macros

Your Cargo.toml dependencies should now look like this:

[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
thiserror = "2"
rust_decimal = "1"
rust_decimal_macros = "1"

A quick note on the crates: reqwest is the de-facto async HTTP client for Rust; the json feature pulls in serde_json and enables the .json() helper. serde with derive gives you #[derive(Deserialize)]. thiserror makes ergonomic custom error enums. rust_decimal provides fixed-point decimals for money.

Store your key in an environment variable so it never lands in source control:

export FINEXLY_API_KEY="your_api_key_here"

The Finexly API Response Shape

Before writing any code, look at what the endpoint returns. A request to the latest-rates endpoint:

curl "https://api.finexly.com/v1/latest?base=USD&symbols=EUR,GBP,JPY&apikey=YOUR_KEY"

returns a small, predictable JSON object:

{
  "success": true,
  "base": "USD",
  "timestamp": 1753660800,
  "rates": {
    "EUR": 0.9213,
    "GBP": 0.7847,
    "JPY": 161.42
  }
}

Two things shape the code below. First, rates is a flat map of ISO 4217 currency code to a floating-point number — that maps cleanly to a Rust HashMap<String, f64>. Second, base tells you what those rates are relative to. To convert USD → EUR you multiply by rates["EUR"]. To convert between two non-base currencies you go through the base: divide out one, multiply in the other.

Making Your First Request with reqwest

Start with the simplest possible async request. Replace src/main.rs with:

#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    let api_key = std::env::var("FINEXLY_API_KEY")
        .expect("Set FINEXLY_API_KEY in your environment");

    let url = format!(
        "https://api.finexly.com/v1/latest?base=USD&symbols=EUR,GBP,JPY&apikey={api_key}"
    );

    let body = reqwest::get(&url).await?.text().await?;
    println!("{body}");

    Ok(())
}

Run it with cargo run and you will see the raw JSON printed. The #[tokio::main] macro sets up the async runtime, reqwest::get performs the request, and each .await? suspends the task until the network responds while propagating any error. This works, but reqwest::get creates a brand-new client on every call — fine for a one-off, wrong for a real application. We will fix that shortly.

Modeling the JSON with Serde

Printing a string is not useful; you want typed data. Define structs that mirror the response and let serde do the parsing:

use serde::Deserialize;
use std::collections::HashMap;

#[derive(Debug, Deserialize)]
pub struct RatesResponse {
    pub success: bool,
    pub base: String,
    pub timestamp: i64,
    pub rates: HashMap<String, f64>,
}

Now swap .text() for .json(), which deserializes the body directly into your struct:

let data: RatesResponse = reqwest::get(&url).await?.json().await?;
println!("1 USD = {} EUR", data.rates["EUR"]);

The json() method reads the response body and runs serde_json under the hood. If a field is missing or the wrong type, you get a clear deserialization error instead of a silent null. Because rates is a HashMap, you can look up any returned currency by its ISO 4217 code.

Building a Reusable Client

The single most common reqwest mistake is creating a new Client for every request. Each client owns its own connection pool, so recreating it throws away keep-alive connections and TLS handshakes. Create one Client, clone it cheaply (it is an Arc internally), and reuse it everywhere. Wrap it in a small struct that hides the URL and key:

use reqwest::Client;

#[derive(Clone)]
pub struct FinexlyClient {
    http: Client,
    api_key: String,
    base_url: String,
}

impl FinexlyClient {
    pub fn new(api_key: impl Into<String>) -> Self {
        Self {
            http: Client::builder()
                .timeout(std::time::Duration::from_secs(10))
                .build()
                .expect("failed to build HTTP client"),
            api_key: api_key.into(),
            base_url: "https://api.finexly.com/v1".to_string(),
        }
    }

    pub async fn latest(
        &self,
        base: &str,
        symbols: &[&str],
    ) -> Result<RatesResponse, FinexlyError> {
        let url = format!("{}/latest", self.base_url);
        let symbols_csv = symbols.join(",");

        let resp = self
            .http
            .get(&url)
            .query(&[
                ("base", base),
                ("symbols", symbols_csv.as_str()),
                ("apikey", self.api_key.as_str()),
            ])
            .send()
            .await?;

        let resp = resp.error_for_status()?;
        let data = resp.json::<RatesResponse>().await?;
        Ok(data)
    }
}

A few things worth calling out. The .query(&[...]) builder handles URL encoding for you, so you never manually concatenate query strings. The .timeout(...) on the builder guards against a hung connection stalling your service. And error_for_status() turns any non-2xx HTTP response into an Err — which brings us to error handling.

Robust Error Handling

Production code has to answer: what happens when the network is down, the key is invalid (403), or you hit the rate limit (429)? Model those cases with a custom error enum using thiserror:

use thiserror::Error;

#[derive(Debug, Error)]
pub enum FinexlyError {
    #[error("HTTP request failed: {0}")]
    Http(#[from] reqwest::Error),

    #[error("API returned an unsuccessful response")]
    Unsuccessful,

    #[error("currency '{0}' was not present in the response")]
    MissingCurrency(String),
}

The #[from] reqwest::Error line means any reqwest failure — DNS, TLS, timeout, or a non-2xx status caught by error_for_status() — is automatically converted into FinexlyError::Http via the ? operator. That is why every .await? in latest() just works.

You can also inspect the status code explicitly when you want tailored behavior — for example, backing off on a 429:

if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
    // sleep and retry, or fall back to a cached rate
}

For a deeper treatment of retries, backoff, and caching strategy, see our guide to currency API caching and error handling best practices.

Converting Between Any Two Currencies

Multiplying by a single rate only works when your source currency is the base. Real applications need arbitrary pairs — EUR → JPY, GBP → CAD — where neither side is the base. The trick is to route through the base currency: divide the amount by the source rate to get the base-equivalent, then multiply by the target rate.

impl FinexlyClient {
    /// Convert `amount` from `from` to `to`, using `from` as the request base.
    pub async fn convert(
        &self,
        amount: f64,
        from: &str,
        to: &str,
    ) -> Result<f64, FinexlyError> {
        let data = self.latest(from, &[to]).await?;

        if !data.success {
            return Err(FinexlyError::Unsuccessful);
        }

        let rate = data
            .rates
            .get(to)
            .ok_or_else(|| FinexlyError::MissingCurrency(to.to_string()))?;

        Ok(amount * rate)
    }
}

Here we simply request rates with from as the base, so the returned rate is already the direct from → to conversion. If you cache one base currency (say USD) and need cross rates, compute them client-side instead: amount / rates[from] * rates[to]. Try it end to end:

#[tokio::main]
async fn main() -> Result<(), FinexlyError> {
    let client = FinexlyClient::new(
        std::env::var("FINEXLY_API_KEY").expect("set FINEXLY_API_KEY"),
    );

    let usd = client.convert(250.0, "EUR", "USD").await?;
    println!("250 EUR = {usd:.2} USD");

    Ok(())
}

Using rust_decimal for Money

f64 is fine for display conversions, but it is the wrong type for storing balances, invoicing, or anything that has to reconcile to the cent. Binary floating point cannot represent 0.1 exactly, and those errors accumulate. For money, use fixed-point decimals:

use rust_decimal::Decimal;
use rust_decimal::prelude::FromPrimitive;
use rust_decimal_macros::dec;

pub fn convert_decimal(amount: Decimal, rate: f64) -> Decimal {
    let rate = Decimal::from_f64(rate).unwrap_or_default();
    (amount * rate).round_dp(2)
}

// usage
let total = convert_decimal(dec!(1999.99), 0.9213);
println!("{total}"); // rounded to 2 decimal places

One important caveat: not every currency has two minor units. JPY and KRW have zero decimal places; some currencies like BHD and KWD have three. Round to the correct number of decimals per currency (driven by the ISO 4217 minor-unit table) rather than hard-coding 2. Our currency converter and the API documentation both reflect these per-currency rules.

Caching to Stay Inside the Free Tier

Exchange rates do not move meaningfully second to second. For most display, checkout, and reporting use cases, refreshing once an hour is plenty — and caching keeps you comfortably inside a free plan no matter how much traffic you get. Add a simple in-memory TTL cache guarded by a Mutex:

use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};

struct CacheEntry {
    data: RatesResponse,
    fetched_at: Instant,
}

pub struct CachedClient {
    inner: FinexlyClient,
    ttl: Duration,
    cache: Mutex<HashMap<String, CacheEntry>>,
}

impl CachedClient {
    pub fn new(api_key: impl Into<String>) -> Self {
        Self {
            inner: FinexlyClient::new(api_key),
            ttl: Duration::from_secs(3600), // 1 hour
            cache: Mutex::new(HashMap::new()),
        }
    }

    pub async fn latest(&self, base: &str) -> Result<RatesResponse, FinexlyError> {
        {
            let cache = self.cache.lock().unwrap();
            if let Some(entry) = cache.get(base) {
                if entry.fetched_at.elapsed() < self.ttl {
                    return Ok(entry.data.clone());
                }
            }
        }

        let fresh = self.inner.latest(base, &[]).await?;
        let mut cache = self.cache.lock().unwrap();
        cache.insert(
            base.to_string(),
            CacheEntry { data: fresh.clone(), fetched_at: Instant::now() },
        );
        Ok(fresh)
    }
}

For this to compile you will want #[derive(Clone)] on RatesResponse. With a one-hour TTL, a single base currency costs you at most 24 requests per day regardless of how many conversions your users perform — a rounding error against a 1,000-request free tier. If you outgrow it, the pricing plans scale linearly. For patterns like stale-while-revalidate and shared caches across instances, see our caching and error handling guide.

Fetching Historical Rates

Back-dated invoices, reporting, and charts need historical data. Finexly exposes a historical endpoint you can call from the same client by adding a method that hits /v1/historical:

impl FinexlyClient {
    pub async fn historical(
        &self,
        date: &str, // "YYYY-MM-DD"
        base: &str,
        symbols: &[&str],
    ) -> Result<RatesResponse, FinexlyError> {
        let url = format!("{}/historical", self.base_url);
        let symbols_csv = symbols.join(",");

        let data = self
            .http
            .get(&url)
            .query(&[
                ("date", date),
                ("base", base),
                ("symbols", symbols_csv.as_str()),
                ("apikey", self.api_key.as_str()),
            ])
            .send()
            .await?
            .error_for_status()?
            .json::<RatesResponse>()
            .await?;

        Ok(data)
    }
}

The response shape is identical to the latest endpoint, so all of your existing parsing and conversion code just works. See the API documentation for the full parameter list and the /v1/timeseries endpoint for ranges of dates.

Common Pitfalls to Avoid

  • Creating a Client per request. Build one, clone it (it is cheap), and reuse it so you keep the connection pool warm.
  • Using f64 for stored balances. Fine for display, wrong for ledgers — use rust_decimal for anything that must reconcile.
  • Hard-coding two decimal places. JPY and KRW have zero; a few currencies have three. Round per currency using the ISO 4217 minor units.
  • Skipping the status check. Deserializing a 403 or 429 body as rates data produces confusing bugs. error_for_status() handles this in one line.
  • Fetching on every conversion. Cache with a TTL and debounce user input so you do not burn your quota.
  • Committing your API key. Read it from the environment or a secrets manager, never a string literal in main.rs.

If you are still comparing providers, our comparison of currency APIs breaks down accuracy, currency coverage, and free-tier limits, and the free currency API guide covers the zero-cost options in more depth.

Frequently Asked Questions

What is the best HTTP client for calling a currency API in Rust?

reqwest is the standard choice for async Rust. It is built on hyper and tokio, handles connection pooling, TLS, and JSON out of the box, and pairs naturally with serde for typed responses. Enable the json feature and reuse a single Client instance across your application.

How do I parse a JSON exchange rate response in Rust?

Define a struct that mirrors the response and derive serde::Deserialize on it. Model the rates field as a HashMap<String, f64> since it is a flat map of currency code to rate, then call .json::<RatesResponse>() on the response and reqwest deserializes it for you.

Should I use f64 or Decimal for currency conversion in Rust?

Use f64 for quick display conversions, but use rust_decimal::Decimal for anything you store or reconcile — balances, invoices, ledgers. Binary floating point cannot represent decimal fractions exactly, so rounding errors accumulate. rust_decimal gives you fixed-point precision.

How often should I fetch exchange rates in a Rust service?

For most checkout, display, and reporting use cases, once an hour is plenty — rates do not move enough within an hour to matter. Cache the response with a one-hour TTL, which keeps you to roughly 24 calls per base currency per day and comfortably inside a free plan.

Can I get historical exchange rates in Rust?

Yes. Add a method that calls the /v1/historical?date=YYYY-MM-DD endpoint on the same client. The JSON shape matches the latest endpoint, so your existing structs and conversion logic work unchanged. This is useful for back-dated invoices, reporting, and charts.

Start Building

Ready to integrate real-time exchange rates into your Rust project? Get your free Finexly API key — no credit card required. Start with 1,000 free requests per month and upgrade as you grow. Check the API documentation for the full endpoint reference and explore pricing plans when you are ready to scale your multi-currency features.

Vlado Grigirov

Senior Currency Markets Analyst & Financial Strategist

Vlado Grigirov is a senior currency markets analyst and financial strategist with over 14 years of experience in foreign exchange markets, cross-border finance, and currency risk management. He has wo...

View full profile →

Zdieľať tento článok