Documentation
Everything you need to integrate xr8.io. Base URL is https://api.xr8.io, every path below is versioned under /v1.
Quickstart
Make your first request in 30 seconds.
curl "https://api.xr8.io/v1/fetch-one?from=USD&to=EUR" \
-H "X-API-Key: YOUR_API_KEY"Authentication
Every endpoint requires an API key except /v1/currencies and /v1/status, which are public. Two methods are accepted:
Header (recommended)
X-API-Key: xr8_live_...
Query parameter
?api_key=xr8_live_...
The query parameter exists for clients that cannot set headers. The WebSocket stream is the main one: browsers cannot attach headers to a handshake, so /v1/stream is authenticated that way. Prefer the header everywhere else, since query strings end up in logs and referrers.
The dashboard endpoints (/v1/keys, /v1/billing, /v1/me) use a session token instead, sent as Authorization: Bearer .... API keys do not work there, and session tokens do not work on the data endpoints.
Get your key from the dashboard. It is shown once at creation and cannot be read back.
Creating a key requires a confirmed email address. Signing up sends a confirmation link; until it is used, the account signs in and reads the dashboard normally but key creation answers 403 with EMAIL_NOT_VERIFIED. Keys already issued are never affected by this.
Response envelope
A successful response always has the same two top-level keys: data holds the payload, meta holds the same six fields everywhere.
{
"data": { },
"meta": {
"request_id": "01K2X7Q9F3B4C5D6E7F8G9H0J1",
"timestamp": 1755439003210,
"source": "ECB",
"cache_hit": true,
"stale": false,
"response_time_ms": 6
}
}request_idIdentifier for this request, echoed in the X-Request-Id header and in every error body. Quote it when you contact us.timestampEpoch milliseconds at which the response was built. Not the age of the data: that is the updated field inside data.sourceWhere the numbers came from: ECB, OXR, COINBASE, FRANKFURTER, static, live or usage depending on the endpoint.cache_hitTrue when the payload came from a warm store rather than a cold read. The X-Cache header carries the same information as HIT or MISS.staleTrue when the snapshot being served was flagged stale by the feeder. The rate is still returned; it is up to you to decide your tolerance.response_time_msReal elapsed milliseconds, measured from the moment the request was stamped. On Cloudflare Workers the clock only advances during I/O, so a route that performs none, such as /v1/currencies, honestly reports 0.
One exception worth knowing: /v1/historical reports response_time_ms: 0 whatever it actually took, because that route builds its meta block separately and does not measure. Every other endpoint measures.
To keep the reference readable, the response examples below show only the data object unless labelled otherwise. Every successful response also carries meta exactly as above. Field names, types and nesting in the examples are what the API returns; the figures themselves are illustrative.
Useful response headers: X-Request-Id, X-Response-Time, X-Cache, X-API-Version, and on rate-limited routes X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset plus Retry-After when you are over a limit.
Plans, access and freshness
Your plan decides four things: which endpoints answer you, how many requests you get, how old the data you are served may be, and whether availability is committed by contract. The first three are enforced at runtime, not decorative. The fourth is contractual and is measured out of band; its clauses are on the pricing page and in the terms.
| Behaviour | Free | Pro | Scale |
|---|---|---|---|
| Max age of a served response | 24h | 2s | 1s |
| Snapshot served | Frozen per window | Live | Live |
| Monthly request quota | 10,000 | 1,000,000 | None |
| Burst ceiling per IP | 120/min | 120/min | 120/min |
| Live fiat endpoints | Yes | Yes | Yes |
| historical, time-series | No | 10 years | Back to 1999 |
| Max time-series range | n/a | 1830 days | 1830 days |
| fx/quote, fx/pairs | No | Yes | Yes |
| crypto/prices, crypto/pairs | No | Yes | Yes |
| WebSocket stream | No | Yes | Yes |
| Availability commitment | None | None | 99.9%/mo |
What the freshness window actually does
Wall-clock time is cut into consecutive windows of your plan length. On Free, whose window is 24 hours, the first request in a window pins the live snapshot and every later request in that same window reads back that exact frozen copy. So the data is stable for the window and its age is bounded by the window. On Pro and Scale the window is 2 and 1 second, shorter than the propagation delay of the store a freeze would use, so those plans read the live snapshot directly instead. The edge cache is keyed by the same window index, so a cached response cannot outlive the window it belongs to.
A frozen snapshot keeps the original timestamp of the data it captured. It is never restamped to the window boundary, so updated always says when the rates were actually observed, even if you read it 23 hours later.
What this is not: it is the maximum age of the response we serve you, not how often the underlying number changes. ECB reference rates are published once per business day, so on a Tuesday afternoon a EUR/USD rate is the same figure on Free and on Scale. The shorter window matters for crypto, which Coinbase reprices continuously, and for how quickly a new ECB publication reaches you. Same statement as on the pricing page.
Cached responses are shared between every key on the same plan, which is intended since those keys are entitled to identical data, and never shared across plans.
Endpoints
GET/v1/fetch-one
Rate for a single currency pair, from the snapshot your plan is entitled to.
Access: All plans
from*stringSource currency code (ISO 4217)to*stringTarget currency codeRequest
curl "https://api.xr8.io/v1/fetch-one?from=USD&to=EUR" \
-H "X-API-Key: YOUR_API_KEY"Response (complete)
{
"data": {
"base": "USD",
"result": { "EUR": 0.9234 },
"updated": "2026-08-17T14:15:02.000Z"
},
"meta": {
"request_id": "01K2X7Q9F3B4C5D6E7F8G9H0J1",
"timestamp": 1755439003210,
"source": "ECB",
"cache_hit": true,
"stale": false,
"response_time_ms": 6
}
}GET/v1/fetch-all
Every rate in the current snapshot, rebased onto one currency.
Access: All plans
fromstringBase currency (default: EUR)- ·results carries every currency present in the snapshot, not a fixed list. The example below is abridged.
- ·A base the snapshot does not carry returns CURRENCY_NOT_SUPPORTED.
Request
curl "https://api.xr8.io/v1/fetch-all?from=EUR" \
-H "X-API-Key: YOUR_API_KEY"Response (data object)
{
"base": "EUR",
"results": { "USD": 1.0834, "GBP": 0.8567, "CHF": 0.9312 },
"updated": "2026-08-17T14:15:02.000Z"
}GET/v1/fetch-multi
Rates for several targets from one base.
Access: All plans
from*stringSource currencyto*stringComma-separated targets, 50 maximumRequest
curl "https://api.xr8.io/v1/fetch-multi?from=USD&to=EUR,GBP,CHF" \
-H "X-API-Key: YOUR_API_KEY"Response (data object)
{
"base": "USD",
"results": { "EUR": 0.9234, "GBP": 0.7891, "CHF": 0.8987 },
"updated": "2026-08-17T14:15:02.000Z"
}GET/v1/fetch-many-to-one
Rates from several bases into one target. The data object names the target, not a base.
Access: All plans
from*stringComma-separated source currenciesto*stringTarget currencyRequest
curl "https://api.xr8.io/v1/fetch-many-to-one?from=USD,GBP,CHF&to=EUR" \
-H "X-API-Key: YOUR_API_KEY"Response (data object)
{
"target": "EUR",
"results": { "USD": 0.9234, "GBP": 1.1702, "CHF": 1.0723 },
"updated": "2026-08-17T14:15:02.000Z"
}GET/v1/fetch-matrix
Full N by N cross-rate matrix.
Access: All plans
currencies*stringComma-separated currencies, 20 maximum- ·The example is abridged: the matrix carries one row per requested currency.
Request
curl "https://api.xr8.io/v1/fetch-matrix?currencies=USD,EUR,GBP,CHF" \
-H "X-API-Key: YOUR_API_KEY"Response (data object)
{
"currencies": ["USD", "EUR", "GBP", "CHF"],
"matrix": {
"USD": { "USD": 1, "EUR": 0.9234, "GBP": 0.7891, "CHF": 0.8987 }
},
"updated": "2026-08-17T14:15:02.000Z"
}GET/v1/convert
Convert an amount between two currencies.
Access: All plans
from*stringSource currencyto*stringTarget currencyamount*numberAmount to convert, zero or greater- ·converted is rounded to the decimals declared for the target currency by /v1/currencies (2 for most, 0 for JPY and the other zero-decimal codes).
Request
curl "https://api.xr8.io/v1/convert?from=USD&to=EUR&amount=1000" \
-H "X-API-Key: YOUR_API_KEY"Response (data object)
{
"from": "USD",
"to": "EUR",
"amount": 1000,
"rate": 0.9234,
"converted": 923.4,
"updated": "2026-08-17T14:15:02.000Z"
}GET/v1/historical
Rates for one past date. Pro plan or higher.
Access: Pro, Scale
date*stringDate in YYYY-MM-DD, not in the futurefromstringBase currency (default: USD)tostringComma-separated targets, 50 maximum. Omit for the whole basket- ·Primary source is the ECB reference series (about 30 currencies quoted on a given date, 47 across the whole series). It is not the 170 codes listed by /v1/currencies.
- ·Coverage depends on to. Omit to and, when a snapshot from the wider secondary provider is already warm in cache, the whole ~170-currency basket is returned. Name currencies in to and the request is served from the ECB series, falling back to the secondary provider only for codes the ECB series does not carry.
- ·rate_date is the publication day actually used. The ECB publishes on business days only, so a weekend or holiday date resolves back to the previous publication day and rate_date differs from date.
- ·How far back you may ask depends on the plan: 10 years on Pro, and on Scale the whole series the sources hold, which for the ECB set starts on 1999-01-04. Outside that window the call returns DATE_OUT_OF_RANGE.
Request
curl "https://api.xr8.io/v1/historical?date=2024-01-15&from=USD&to=EUR" \
-H "X-API-Key: YOUR_API_KEY"Response (data object)
{
"base": "USD",
"date": "2024-01-15",
"rate_date": "2024-01-15",
"results": { "EUR": 0.9156 }
}GET/v1/time-series
Daily closing rates for one pair over a date range, from the ECB reference series. Pro plan or higher.
Access: Pro, Scale
fromstringSource currency (default: USD)to*stringTarget currencystart_date*stringStart date YYYY-MM-DD, inclusiveend_date*stringEnd date YYYY-MM-DD, inclusive, clamped to today- ·Currency coverage is much narrower than /v1/currencies. Only these 47 codes ever appear in the series: ARS, AUD, BGN, BRL, CAD, CHF, CNY, CYP, CZK, DKK, DZD, EEK, EUR, GBP, GRD, HKD, HRK, HUF, IDR, ILS, INR, ISK, JPY, KRW, LTL, LVL, MAD, MTL, MXN, MYR, NOK, NZD, PHP, PLN, ROL, RON, RUB, SEK, SGD, SIT, SKK, THB, TRL, TRY, TWD, USD, ZAR. About 30 are quoted on any given date; the rest (BGN, HRK, RUB and the pre-euro legacy codes) only exist for part of the series. Anything else returns CURRENCY_NOT_SUPPORTED before any upstream call is made.
- ·The requested range is bounded. The ceiling is 1830 days on Pro and on Scale. A wider range returns DATE_OUT_OF_RANGE and asks you to page.
- ·start_date must be on or after 1999-01-04, the first day of the ECB reference series, and within your plan window. An end_date in the future is clamped to today rather than rejected, and end_date in the response reflects the clamped value.
- ·Only publication days appear. Weekends and ECB holidays are absent from rates, they are not filled with the previous close. Asking for from equal to to returns the publication calendar with a rate of 1 on every day.
Request
curl "https://api.xr8.io/v1/time-series?from=USD&to=EUR&start_date=2024-01-01&end_date=2024-01-05" \
-H "X-API-Key: YOUR_API_KEY"Response (data object)
{
"base": "USD",
"target": "EUR",
"start_date": "2024-01-01",
"end_date": "2024-01-05",
"rates": [
{ "date": "2024-01-02", "rate": 0.91058 },
{ "date": "2024-01-03", "rate": 0.91517 },
{ "date": "2024-01-04", "rate": 0.91324 },
{ "date": "2024-01-05", "rate": 0.9154 }
]
}GET/v1/currencies
The 170 currency codes the live endpoints can quote, with metadata. Public, no API key required.
Access: Public
- ·This list describes the live endpoints. Historical coverage is far narrower, see /v1/historical and /v1/time-series.
- ·decimals is what /v1/convert rounds its converted amount to.
Request
curl "https://api.xr8.io/v1/currencies"Response (data object)
{
"data": [
{ "code": "USD", "name": "US Dollar", "symbol": "$", "flag": "🇺🇸", "decimals": 2 },
{ "code": "EUR", "name": "Euro", "symbol": "€", "flag": "🇪🇺", "decimals": 2 }
]
}GET/v1/status
Live service state. Public, no API key required. This is what the status page reads.
Access: Public
- ·Every component state is observed, not declared. Live feeder and WebSocket are derived from the age and reachability of the snapshot store, Authentication from a real database round trip, API from the fact that this request was answered.
- ·Each status is one of operational, degraded or down. The feeder reads operational up to 30 seconds of snapshot age, degraded up to 300 seconds, down beyond that or when no snapshot exists anywhere.
- ·Overall status reads down only when every dependency is out, degraded when any one of them is not operational.
- ·Responses carry Cache-Control: public, max-age=10, so a polling status page cannot hammer the backend.
Request
curl "https://api.xr8.io/v1/status"Response (data object)
{
"status": "operational",
"components": [
{ "name": "API", "status": "operational" },
{ "name": "Live feeder", "status": "operational" },
{ "name": "Authentication", "status": "operational" },
{ "name": "WebSocket", "status": "operational" }
],
"feeder": {
"last_update": "2026-08-17T14:15:02.000Z",
"age_seconds": 3
},
"checked_at": "2026-08-17T14:15:05.000Z"
}GET/v1/fx/quote
Reference mid rate for a pair. Pro plan or higher.
Access: Pro, Scale
pair*stringSix-character pair such as EURUSD- ·This is not a dealable quote and the response says so in three ways: rate_type is reference_mid, dealable is false, and disclaimer spells it out. It is the rate published by our sources, not a price at which any counterparty has offered to trade. Do not use it as an execution price.
- ·There is no bid, ask or spread. An earlier version of this endpoint computed them from a constant margin held in the source code. No market produced those numbers, so they were removed rather than left in place. Our fiat source publishes one reference fixing per business day and a two-way price requires an actual counterparty, so there is no honest way to derive one from what we hold.
- ·mid is rounded to 6 decimal places. timestamp is the epoch milliseconds of the snapshot the rate came from.
- ·pair must be exactly 6 characters. Anything else returns CURRENCY_PAIR_INVALID.
Request
curl "https://api.xr8.io/v1/fx/quote?pair=EURUSD" \
-H "X-API-Key: YOUR_API_KEY"Response (data object)
{
"pair": "EURUSD",
"mid": 1.083406,
"timestamp": 1755439002000,
"rate_type": "reference_mid",
"dealable": false,
"disclaimer": "Reference mid rate, not a dealable quote. It is the rate published by our sources (ECB reference rates for fiat), not a price at which any counterparty has offered to trade. Do not use it as an execution price."
}GET/v1/fx/pairs
The 56 ordered pairs built from the eight major currencies. Pro plan or higher.
Access: Pro, Scale
- ·Static list, built from EUR, USD, GBP, JPY, CHF, CAD, AUD and NZD. The example is abridged.
Request
curl "https://api.xr8.io/v1/fx/pairs" \
-H "X-API-Key: YOUR_API_KEY"Response (data object)
{
"pairs": ["EURUSD", "EURGBP", "EURJPY", "EURCHF"],
"count": 56
}GET/v1/crypto/prices
Cryptocurrency spot prices from Coinbase. Pro plan or higher.
Access: Pro, Scale
symbols*stringComma-separated crypto symbolsvs_currencystringQuote currency (default: USD)- ·Precision follows the order of magnitude of the price: 8 significant digits, never fewer than 2 decimals. A fixed 2-decimal rounding used to flatten every sub-cent asset such as SHIB or PEPE to exactly 0, which is why the example below shows a micro-cap alongside BTC.
- ·Prices below 0.000001 are written as plain decimal literals rather than the exponential form JSON.stringify would emit, so a naive parser reads them correctly.
- ·A symbol Coinbase does not quote is silently absent from prices rather than returned as null.
- ·Upstream prices are cached for 60 seconds.
Request
curl "https://api.xr8.io/v1/crypto/prices?symbols=BTC,ETH,SHIB" \
-H "X-API-Key: YOUR_API_KEY"Response (data object)
{
"prices": {
"BTC": { "price": 118342.51, "vs_currency": "USD" },
"ETH": { "price": 3289.67, "vs_currency": "USD" },
"SHIB": { "price": 0.0000082013, "vs_currency": "USD" }
}
}GET/v1/crypto/pairs
The 50 crypto symbols the price endpoint knows about, with the quote currencies offered. Pro plan or higher.
Access: Pro, Scale
- ·Static list. The example is abridged.
Request
curl "https://api.xr8.io/v1/crypto/pairs" \
-H "X-API-Key: YOUR_API_KEY"Response (data object)
{
"pairs": [
{ "symbol": "BTC", "name": "Bitcoin", "vs_currencies": ["USD", "EUR", "GBP", "JPY"] },
{ "symbol": "ETH", "name": "Ethereum", "vs_currencies": ["USD", "EUR", "GBP", "JPY"] }
],
"count": 50
}GET/v1/usage
Usage for the calling key owner over the current calendar month, in UTC.
Access: All plans
- ·requests_limit is null on Scale, which has no monthly quota.
- ·cache_hit_rate is a whole percentage, avg_response_time_ms a rounded millisecond count, both computed over the same month.
Request
curl "https://api.xr8.io/v1/usage" \
-H "X-API-Key: YOUR_API_KEY"Response (data object)
{
"plan": "pro",
"period": "2026-08",
"requests_used": 45231,
"requests_limit": 1000000,
"endpoints": { "/v1/fetch-one": 41022, "/v1/fetch-all": 4209 },
"cache_hit_rate": 93,
"avg_response_time_ms": 7
}Streaming (WebSocket)
wss://api.xr8.io/v1/stream pushes every snapshot the feeder produces. Pro plan or higher.
- ·Authentication is by query parameter, not by header: browsers cannot set headers on a WebSocket handshake. Connect to wss://api.xr8.io/v1/stream?api_key=YOUR_API_KEY.
- ·A plain HTTP GET without an upgrade header returns 426 with a BAD_REQUEST body. A key on Free returns 403 with PLAN_REQUIRED. A backend that cannot accept the upgrade returns 503 with DATA_UNAVAILABLE.
- ·The handshake goes through the rate limiter, so each connection attempt counts as one request against your quota and your burst ceiling. The stream itself is not metered per message.
- ·On connect you receive one message of type snapshot carrying the current state, so you have data before the next push. Every feeder push after that arrives as a message of type rates with the same shape.
- ·Keepalive: send the raw string ping and you get back the raw string pong, answered by the runtime without waking the backend. A JSON {"type":"ping"} is also accepted and answered with {"type":"pong","ts":...}.
Connect
const ws = new WebSocket(
'wss://api.xr8.io/v1/stream?api_key=YOUR_API_KEY'
)
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
// msg.type is 'snapshot' on connect, then 'rates' on every push
if (msg.type === 'snapshot' || msg.type === 'rates') {
console.log(msg.data.base, msg.data.rates.USD, msg.data.timestamp)
}
}Message (abridged rates)
{
"type": "rates",
"data": {
"base": "EUR",
"rates": { "USD": 1.0834, "GBP": 0.8567 },
"source": "ECB",
"timestamp": 1755439002000,
"stale": false
},
"ts": 1755439002114
}Errors
Errors return a 4xx or 5xx status with a consistent JSON envelope. There is no meta block on an error, and docs links straight to the anchor for that code in the list below.
{
"error": {
"code": "CURRENCY_NOT_SUPPORTED",
"message": "Currency XYZ not available",
"docs": "https://xr8.io/docs/errors#CURRENCY_NOT_SUPPORTED",
"request_id": "01K2X7Q9F3B4C5D6E7F8G9H0J1"
}
}Branch on code, never on message: the codes are stable, the wording is not. Always log request_id, it is the only handle we have on your specific request.
Error codes
Every code the API defines, with the status it comes back on. A few are reserved: they are part of the published set but no route emits them today, and each one says so and points at the code you will actually receive.
INVALID_CREDENTIALSHTTP 401POST /v1/auth/login was given an email that has no account, or the right email with the wrong password. The two cases are deliberately reported identically so the endpoint cannot be used to test whether an address is registered.
Fix: Check the email and password. Reset the password from the login page if you are unsure.
TOKEN_EXPIREDHTTP 401Reserved. No route emits this code today: an expired dashboard token fails signature and expiry verification in one step and comes back as TOKEN_INVALID.
Fix: Handle it as you would TOKEN_INVALID, by refreshing the session token.
TOKEN_INVALIDHTTP 401The dashboard token in the Authorization header did not verify. It is malformed, signed with another secret, or past its expiry. Applies to /v1/keys, /v1/billing, /v1/me and the token refresh and password reset routes.
Fix: Sign in again to obtain a fresh token. This code never applies to API keys, which use X-API-Key.
UNAUTHORIZEDHTTP 401No credential was presented where one is required. Either the API key is missing from both the X-API-Key header and the api_key query parameter, or the Authorization header is absent or not a Bearer token, or the token verified but its user no longer exists.
Fix: Send the credential the route expects: X-API-Key for the data endpoints, Bearer token for the dashboard endpoints.
EMAIL_ALREADY_EXISTSHTTP 409Reserved. No route emits this code any more. POST /v1/auth/register used to answer it when the address was already taken, which turned sign-up into a way of testing whether a given address has an account here. Registration now answers the same thing in every case and moves the difference into the email it sends: a confirmation link if the address was free, a note that an account already exists if it was not.
Fix: Nothing to branch on. Registration always returns 200 with the same message; the mailbox holds the answer.
EMAIL_NOT_VERIFIEDHTTP 403POST /v1/keys was called by an account whose email address has never been confirmed. Signing up sends a confirmation link, and until it is used the account can sign in and read the dashboard but cannot create an API key.
Fix: Open the link in the confirmation email. If it has expired, is lost, or was never received, request a new one from the banner in the dashboard or with POST /v1/auth/resend, which takes {"email": "..."} and answers identically whatever the state of the address. Completing a password reset also confirms the address, since it proves the same thing.
API_KEY_INVALIDHTTP 401The API key presented does not match any key on record. Usually a truncated copy and paste, a key from another environment, or a stray whitespace character.
Fix: Copy the key again from the dashboard. Keys are shown once at creation and cannot be read back afterwards.
API_KEY_REVOKEDHTTP 401The key exists but has been revoked. Revocation is permanent and takes effect within the five-minute lifetime of the cached authentication session.
Fix: Create a new key from the dashboard and roll it out.
API_KEY_NOT_FOUNDHTTP 404Reserved. No route emits this code today: deleting a key that does not belong to you, or does not exist, succeeds silently rather than reporting the difference.
Fix: Nothing to do. List your keys to confirm the current state.
QUOTA_EXCEEDEDHTTP 429The monthly request quota for the key owner is used up. The quota is counted per calendar month in UTC across every key on the account, and it is 10,000 on Free, 1,000,000 on Pro and unlimited on Scale.
Fix: Wait for the reset given by X-RateLimit-Reset and Retry-After, or upgrade the plan. X-RateLimit-Remaining tells you how much is left before you get here.
RATE_LIMIT_EXCEEDEDHTTP 429A ceiling on request frequency was hit. On the data endpoints it is 120 requests per minute, counted per source IP per key owner in fixed one-minute buckets, on every plan including Scale. The unauthenticated routes have their own, tighter, per-IP ceilings: sign-in 10 per 5 minutes and 60 per hour; sign-up 5 per hour and 20 per day; forgotten password and confirmation resend 3 per hour and 10 per day; token submission 30 per hour; the contact form 3 per hour and 10 per day. The routes that send an email are metered a second time per destination address, at 3 per hour and 10 per day, so no one can be mailbombed through them from many sources.
Fix: Back off for the number of seconds in Retry-After and spread the traffic. These ceilings are separate from the monthly quota and cannot be lifted by upgrading.
CURRENCY_NOT_SUPPORTEDHTTP 400A currency code in the request is not in the snapshot or series being read. On the live endpoints that means it is outside the 170 codes listed by /v1/currencies or absent from the current snapshot. On /v1/historical and /v1/time-series it usually means the code is outside the ECB reference series, which is far narrower than /v1/currencies.
Fix: Check the code against /v1/currencies for live rates, and against the 47-code list documented under time-series for history. The message names the offending codes.
CURRENCY_PAIR_INVALIDHTTP 400The pair parameter on /v1/fx/quote was missing or was not exactly 6 characters.
Fix: Concatenate the two ISO codes with no separator, as in EURUSD.
TOO_MANY_SYMBOLSHTTP 400Too many currencies in one call. The ceiling is 50 targets on /v1/fetch-multi and /v1/historical, and 20 currencies on /v1/fetch-matrix, where the response grows as the square of the count.
Fix: Split the request, or call /v1/fetch-all and select what you need client side.
PLAN_REQUIREDHTTP 403The endpoint is not included in the calling key plan. History, the FX and crypto endpoints and the WebSocket stream all need Pro or higher; Free carries the live fiat endpoints only.
Fix: Upgrade the plan, or use an endpoint your plan includes. The access table above lists which plan unlocks what.
HISTORICAL_NOT_AVAILABLEHTTP 403Reserved. No route emits this code today: a plan without history returns PLAN_REQUIRED, and a date outside the plan window returns DATE_OUT_OF_RANGE.
Fix: Handle PLAN_REQUIRED and DATE_OUT_OF_RANGE instead.
DATE_OUT_OF_RANGEHTTP 400 or 403A date is outside a boundary. It is 400 when the date is in the future, before 1999-01-04 on /v1/time-series, or when the requested range is wider than the allowed span. It is 403 when the date is further back than the plan allows, which is 10 years on Pro and, on Scale, further back than the series itself goes.
Fix: Read the message, which states the boundary that was crossed and the value it holds. Split wide ranges into windows of at most 1830 days.
DATA_UNAVAILABLEHTTP 503The data exists in principle but no source could serve it right now: no cached snapshot at all, the historical provider unreachable, the crypto upstream failing, or the streaming backend refusing the upgrade.
Fix: Retry with a backoff. Unlike NOT_SUPPORTED, this one is temporary. Check the status page or /v1/status if it persists.
STALE_DATAHTTP 200Reserved. Staleness is never reported as an error, because a stale rate is still an answer. It is reported inside every successful response as meta.stale, which turns true when the snapshot being served is flagged stale by the feeder.
Fix: Read meta.stale and the updated field on each response and decide your own tolerance. /v1/status publishes the age of the feeder snapshot.
NOT_SUPPORTEDHTTP 501The request is well formed and authorised, but the capability does not exist and is not coming back. Today this is only /v1/fx/ohlc. OHLC candles were removed rather than repaired: the previous implementation manufactured open, high and low around the current mid and returned a single bar whatever the period asked for. Our fiat source publishes one figure per business day, so there is no intraday path from which a candle could be derived.
Fix: Never retry this one. Use /v1/time-series for the daily close series over a range, or /v1/fx/quote for the current reference mid.
BAD_REQUESTHTTP 400, and 426 on /v1/streamThe request is malformed: a required parameter is missing, a date is not YYYY-MM-DD, start_date is after end_date, amount is not a positive number, or a request body failed validation. On /v1/stream it is also returned with status 426 when a plain HTTP GET arrives without a WebSocket upgrade.
Fix: The message names the parameter at fault. On /v1/stream, connect with a WebSocket client rather than curl.
NOT_FOUNDHTTP 404No route matches this method and path. Note that the docs link on this particular error points at the docs root rather than at this anchor.
Fix: Check the path against the endpoint list above, including the /v1 prefix.
INTERNAL_ERRORHTTP 500An unhandled exception. The response carries no detail on purpose; the detail is in our logs, keyed by request_id.
Fix: Retry once, then send us the request_id through the contact page if it repeats.
Migrate from FastForex
The fetch endpoints are a near drop-in replacement. Change your base URL and move the key into the X-API-Key header, and those calls work. Read the notes under the table before you switch anything that depends on history or on FX quotes.
api.fastforex.io/fetch-one?from=USD&to=EURapi.xr8.io/v1/fetch-one?from=USD&to=EURapi.fastforex.io/fetch-multi?from=USD&to=EUR,GBPapi.xr8.io/v1/fetch-multi?from=USD&to=EUR,GBPapi.fastforex.io/fetch-all?from=EURapi.xr8.io/v1/fetch-all?from=EURapi.fastforex.io/convert?from=USD&to=EUR&amount=100api.xr8.io/v1/convert?from=USD&to=EUR&amount=100api.fastforex.io/historical?date=2024-01-15api.xr8.io/v1/historical?date=2024-01-15api.fastforex.io/Header: api_key=...api.xr8.io/v1/Header: X-API-Key: ...- ·Historical and time-series coverage here comes from the ECB reference series, which quotes far fewer currencies than the live endpoints. If your FastForex integration pulls history for an exotic code, check it against the 47-code list under time-series before you cut over.
- ·There is no bid, ask or spread on any endpoint, and no OHLC. /v1/fx/quote returns a reference mid labelled as such. If your integration reads a spread, it has nothing to read here.
- ·Price, checked against the published lists in August 2026: FastForex asks $18 a month for a million requests on its One plan; xr8 Pro is $9 for the same volume, and xr8 Scale is $29 with a contractual commitment of 99.9% monthly availability, which FastForex offers on no plan. Weigh that against what each side actually serves: FastForex advertises decades more history and market-data feeds behind its rates, where xr8 serves ECB reference rates and Coinbase public prices. If you need history from before 1999, or prices you can deal on, the cheaper option here is the wrong one.