Skip to content
Rust guide

Exchange rate API in Rust with reqwest and serde

An async Rust client for live and historical exchange rates: reqwest with default headers, serde structs, a thiserror error enum, rust_decimal conversion and a tokio RwLock cache.

Last updated: · fxapi team

This guide shows how to use an exchange rate API in Rust: an async client built on reqwest and tokio, serde structs for the JSON responses, a typed error enum covering every status code fxapi returns, decimal-safe conversion and a shared cache. The code compiles against current stable Rust and the crate versions below.

Setup

Get a key from the free sign-up and export it as FXAPI_KEY. Then add the dependencies:

cargo add reqwest --features json
cargo add serde --features derive
cargo add serde_json thiserror
cargo add tokio --features full
cargo add rust_decimal

Every request is a GET to https://api.fxapi.com/v1/<endpoint> with the key in the apikey header.

Response types and errors

/v1/latest, /v1/historical and /v1/convert share one shape:

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

#[derive(Debug, Deserialize)]
pub struct Rate {
    pub code: String,
    pub value: f64,
}

#[derive(Debug, Deserialize)]
pub struct Meta {
    pub last_updated_at: String, // ISO 8601, UTC
}

#[derive(Debug, Deserialize)]
pub struct RatesResponse {
    pub meta: Meta,
    pub data: HashMap<String, Rate>,
}

#[derive(Debug, thiserror::Error)]
pub enum FxError {
    #[error("invalid API key")]
    Unauthorized,
    #[error("endpoint or option not included in your plan")]
    Forbidden,
    #[error("validation error: {0}")]
    Validation(serde_json::Value),
    #[error("rate limit reached (monthly quota left: {0:?})")]
    RateLimited(Option<String>),
    #[error("unexpected status {0}")]
    Status(reqwest::StatusCode),
    #[error(transparent)]
    Http(#[from] reqwest::Error),
}

An async exchange rate API client in Rust

use reqwest::header::{HeaderMap, HeaderValue};
use reqwest::StatusCode;
use std::time::Duration;

pub struct FxClient {
    http: reqwest::Client,
}

impl FxClient {
    pub fn new(api_key: &str) -> Result<Self, FxError> {
        let mut key = HeaderValue::from_str(api_key).expect("API key must be a valid header value");
        key.set_sensitive(true);
        let mut headers = HeaderMap::new();
        headers.insert("apikey", key);

        let http = reqwest::Client::builder()
            .default_headers(headers)
            .timeout(Duration::from_secs(10))
            .build()?;
        Ok(Self { http })
    }

    async fn get(&self, endpoint: &str, query: &[(&str, &str)]) -> Result<RatesResponse, FxError> {
        let res = self
            .http
            .get(format!("https://api.fxapi.com/v1/{endpoint}"))
            .query(query)
            .send()
            .await?;

        match res.status() {
            StatusCode::OK => Ok(res.json().await?),
            StatusCode::UNAUTHORIZED => Err(FxError::Unauthorized),
            StatusCode::FORBIDDEN => Err(FxError::Forbidden),
            StatusCode::UNPROCESSABLE_ENTITY => {
                let body: serde_json::Value = res.json().await.unwrap_or_default();
                Err(FxError::Validation(body["errors"].clone()))
            }
            StatusCode::TOO_MANY_REQUESTS => Err(FxError::RateLimited(
                res.headers()
                    .get("x-ratelimit-remaining-quota-month")
                    .and_then(|v| v.to_str().ok())
                    .map(String::from),
            )),
            other => Err(FxError::Status(other)),
        }
    }

    pub async fn latest(&self, base: &str, currencies: Option<&str>) -> Result<RatesResponse, FxError> {
        let mut q = vec![("base_currency", base)];
        if let Some(c) = currencies {
            q.push(("currencies", c));
        }
        self.get("latest", &q).await
    }

    pub async fn historical(&self, date: &str, base: &str, currencies: Option<&str>) -> Result<RatesResponse, FxError> {
        let mut q = vec![("date", date), ("base_currency", base)];
        if let Some(c) = currencies {
            q.push(("currencies", c));
        }
        self.get("historical", &q).await
    }

    /// Requires the Basic plan or higher (FxError::Forbidden otherwise).
    pub async fn convert(&self, value: &str, base: &str, currencies: &str) -> Result<RatesResponse, FxError> {
        self.get("convert", &[("value", value), ("base_currency", base), ("currencies", currencies)]).await
    }
}

reqwest::Client holds a connection pool – create it once and share it (it’s cheap to clone). A 422 carries an errors object keyed by parameter name and doesn’t count against your quota; 429 means the monthly quota is exhausted or, on the Free plan, more than 10 requests were made in a minute.

Latest and historical rates

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let fx = FxClient::new(&std::env::var("FXAPI_KEY")?)?;

    let latest = fx.latest("EUR", Some("USD,GBP,JPY")).await?;
    println!("{} EUR/USD {}", latest.meta.last_updated_at, latest.data["USD"].value);

    match fx.historical("2024-12-31", "USD", Some("EUR")).await {
        Ok(hist) => println!("USD/EUR on 2024-12-31: {}", hist.data["EUR"].value),
        Err(FxError::RateLimited(left)) => eprintln!("rate limited, quota left: {left:?}"),
        Err(e) => return Err(e.into()),
    }
    Ok(())
}

Historical values are end-of-day UTC rates from 1999-01-01 up to yesterday. For series, /v1/range (Professional plan) returns daily to minute-level data – see the time series API.

Currency conversion with rust_decimal

On Basic and up, fx.convert("250", "USD", "EUR") returns the converted amount. Otherwise, convert locally – via Decimal, not f64 arithmetic:

use rust_decimal::prelude::*;

fn convert(amount: Decimal, rate: f64, digits: u32) -> Option<Decimal> {
    // `rate.to_string()` gives the shortest exact representation of the JSON value
    let rate = Decimal::from_str(&rate.to_string()).ok()?;
    Some((amount * rate).round_dp_with_strategy(digits, RoundingStrategy::MidpointNearestEven))
}

// let eur = convert(Decimal::new(25000, 2), latest.data["EUR"].value, 2);

Take digits from the decimal_digits field of /v1/currencies (JPY 0, KWD 3). The rounding guide explains half-even versus half-up and cross rates.

Caching with tokio::sync::RwLock

Rates change only as often as your plan updates – daily on Free, hourly on Basic, every 60 seconds on Professional and Enterprise (pricing). Cache one all-currency response and share it across tasks:

use std::sync::Arc;
use std::time::Instant;
use tokio::sync::RwLock;

pub struct RateCache {
    fx: FxClient,
    ttl: Duration,
    inner: RwLock<Option<(Instant, Arc<RatesResponse>)>>,
}

impl RateCache {
    pub async fn latest(&self) -> Result<Arc<RatesResponse>, FxError> {
        if let Some((at, data)) = self.inner.read().await.as_ref() {
            if at.elapsed() < self.ttl {
                return Ok(data.clone());
            }
        }
        let mut guard = self.inner.write().await;
        if let Some((at, data)) = guard.as_ref() {
            if at.elapsed() < self.ttl {
                return Ok(data.clone()); // refreshed by another task meanwhile
            }
        }
        match self.fx.latest("USD", None).await {
            Ok(fresh) => {
                let fresh = Arc::new(fresh);
                *guard = Some((Instant::now(), fresh.clone()));
                Ok(fresh)
            }
            // On 429 or network errors, serve the last known rates if we have them
            Err(e) => guard.as_ref().map(|(_, d)| d.clone()).ok_or(e),
        }
    }
}

With a USD base you can derive any cross rate as data[to].value / data[from].value, so one request per TTL covers every pair. Across multiple instances, store the JSON in Redis – see caching exchange rates.

Next steps

The examples in this guide use fxapi, a foreign exchange rates API with a free plan of 300 requests a month – no credit card required.

Frequently asked questions

Which crates do I need to call an exchange rate API from Rust?
reqwest (with the json feature), serde, serde_json and tokio. thiserror and rust_decimal are optional but make error handling and money arithmetic cleaner.
Should I store exchange rates as f64 in Rust?
Deserializing the rate as f64 is fine, but convert it to rust_decimal::Decimal before multiplying money and round once to the currency’s minor units. See currency rounding and precision.
How do I avoid leaking the API key in logs?
Send it in the apikey header rather than the query string, and mark the HeaderValue as sensitive with set_sensitive(true) so it’s redacted from Debug output.
Can I convert currencies on the Free plan?
Yes – fetch /v1/latest with your source currency as base_currency and multiply by the target rate. /v1/convert returns converted amounts directly from the Basic plan up.
Can I generate a Rust client instead of writing one?
Yes. OpenAPI Generator has a Rust target that works with the OpenAPI 3.1 spec. This guide uses reqwest directly so you control timeouts, error types and caching.
Free plan · no credit card

Get your free exchange rate API key

300 requests a month, latest and historical rates, fluctuation and averages – free forever. Upgrade when you need faster updates or more requests.