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
- Full reference: API documentation and the OpenAPI spec.
- Check quota usage with
/v1/status– it doesn’t count against your quota. - Payments and wallets: fintech payments.
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?
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?
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?
/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?
fxapi-rs crate on crates.io. This guide uses reqwest directly so you control timeouts, error types and caching.