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.

The fxapi-rs crate

An fxapi-rs crate is published on crates.io (cargo add fxapi-rs). Its README doesn’t yet document the method signatures, so this guide sticks to reqwest. The client above is small enough to drop into any project.

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.
Is there an official Rust crate?
There is an fxapi-rs crate on crates.io. 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.