On this page
CrestRate API Reference
Multi-source aggregated exchange rates for businesses that charge or pay across currencies. You choose which side the rate favors, markup is configured per API key, and quotes lock both the rate and the markup.
- Base URL —
https://api.crestrate.com. Every rate endpoint lives under/v1, for examplehttps://api.crestrate.com/v1/rate - First integration — start with the Quickstart; four copy-pasteable snippets get your first call working
- Machine-readable spec — OpenAPI 3.1
- Plans, quotas and rate limits — Pricing
- Service status — https://crestrate.com/status
Use cases
If you price in one currency and settle in another, this API fits. We don't segment customers by industry; common uses:
- Cross-border e-commerce — convert list prices into the customer's currency
- SaaS and subscriptions — bill each customer in their local currency
- Visitor spending in Japan — hotels, retail, duty-free shops and POS systems price in yen; visitors pay in Hong Kong dollars, yuan, US dollars or euros
- B2B import and export — invoice in the buyer's currency, and lock the rate with a quote for as long as your offer is valid
- Platforms and marketplaces — charge buyers with
strategy=max, pay sellers out withstrategy=min - Travel, study-abroad and visa agencies — quote in the client's currency
- Games and digital goods — set regional prices
Supported currencies
Real-time data is available for the following currencies, in any base and quote combination:
| Currency | Code | Smallest unit for amounts |
|---|---|---|
| US dollar | USD |
0.01 |
| Japanese yen | JPY |
1 |
| Euro | EUR |
0.01 |
| Hong Kong dollar | HKD |
0.01 |
| Chinese yuan | CNY |
0.01 |
Real-time data temporarily unavailable: TWD, SGD, GBP, AUD, KRW, MYR, THB. They will open once their data is stable.
Until then, a request that uses one of those codes returns HTTP 422 currency_not_available, with the list of available currencies in supported. A code we don't recognize at all returns HTTP 400 unsupported_currency. Currency codes are case-insensitive in requests and always uppercase in responses.
How the rate is chosen
Every request prices against every currently healthy source at once, not a single feed:
- Each source's rate table is normalized to a common base, and your pair is derived as a cross rate
- Sources with stale data, and sources that don't cover the pair, are dropped first
- The anchor is the median of the freshest available tier of sources (realtime → hourly → daily). That is the most reliable estimate of the current market price
- Sources deviating from the anchor beyond the fault threshold are treated as corrupt data and dropped
- A surviving rate is selected per
strategy:maxtakes the highest,minthe lowest,medianthe one closest to the median. It is then clamped both ways: above the capcap_rateit is pulled down, below the floorfloor_rateit is lifted up - Markup is applied last, in the direction of your side: up when charging, down when paying
The response always states which source the final rate came from, what every other source quoted, and any source that was dropped along with a machine-readable code (excluded_code). Sources are identified by stable anonymous IDs such as source_a.
If too few valid sources remain, you get HTTP 503 no_rate instead of a number we can't stand behind.
You choose which side the rate favors
On the same pair, a higher rate is better when you charge and a lower one is better when you pay. Tell us your side with strategy; the response field side states which side was applied.
strategy |
side |
Favors | Typical use |
|---|---|---|---|
max (default) |
charge |
The party charging: the highest rate, so the same list price brings in more | E-commerce, SaaS, hotels, B2B invoicing |
min |
pay |
The party paying: the lowest rate, so the same amount owed costs less | Marketplace seller payouts, paying overseas suppliers |
median |
charge |
Neutral: the rate closest to the median of all sources | Bookkeeping, internal reporting |
strategy is case-insensitive; any other value returns HTTP 400 bad_request. side also sets the direction of markup and of rounding:
| Item | charge (charging) |
pay (paying) |
|---|---|---|
| Markup | The rate moves up; the customer pays more | The rate moves down; you pay out less |
payable rounding |
Up to the currency's smallest unit | Down to the currency's smallest unit |
gain_vs_mid |
payable - mid_payable (extra collected) |
mid_payable - payable (saved on payout) |
Both directions are guarded. The selected rate is compared with the freshness anchor anchor_rate: above the cap cap_rate it is pulled back (capped: true); below the floor floor_rate it is lifted back (floored: true). When charging it can't sit meaningfully above the live market, and when paying it can't sit meaningfully below it — so it holds up when the other party checks a public converter. Markup is your own explicit setting and is applied after this clamp.
Markup
Markup is the margin you add on top of the selected rate, in basis points (bps): 100 bps = 1%.
| Item | Details |
|---|---|
| Where to set it | The API keys list in the dashboard — each key has its own markup |
| Default markup | The markup this key uses for every currency |
| Per-currency overrides | Override the default for a given quote currency, e.g. HKD at 120 bps and JPY at 200 bps. Only currencies with real-time data can be overridden |
| Range | 0 to 1,000 bps (0% to 10%). 0 means explicitly no markup |
| When it applies | Immediately after saving, from the next request |
| Quotes | Markup is locked in when a quote is created. Changing it later does not affect quotes already issued |
Precedence, highest first. The response field markup_source says which one applied:
- The key's override for this currency →
key_currency - The key's default markup →
key_default - The platform default →
platform
Same-currency pairs (e.g. USD → USD) involve no conversion, so no markup is applied and markup_source is none.
Direction and rounding follow side, so the result always favors you. Charging: raw_rate × (1 + markup_bps/10000), amounts round up. Paying: raw_rate × (1 - markup_bps/10000), amounts round down.
Worked example (illustrative numbers, not live rates; for an easy comparison both cases assume a selected rate of 7.8000): a US$1,000 order settled in Hong Kong dollars, with a markup of 150 bps (1.5%).
| Scenario | No markup | Markup 150 bps | Difference |
|---|---|---|---|
You're charging (strategy=max) |
Rate 7.8000, customer pays HK$7,800.00 | Rate 7.9170, customer pays HK$7,917.00 | HK$117.00 more collected |
You're paying (strategy=min) |
Rate 7.8000, you pay HK$7,800.00 | Rate 7.6830, you pay HK$7,683.00 | HK$117.00 less paid out |
Preview before saving. When you set a markup, the dashboard shows the effect as you adjust the value: how much more the customer pays, or how much less you pay out.
Disclosure. Where markups must be disclosed (for example, consumer payments in the EU), disclose yours to your customers.
Locked quotes
A quote freezes the price you show your customer until it expires, so rate movement between display and payment can't produce mismatched totals.
POST /v1/quoteslocks the finalrate, themarkup_bpsin effect, and the settlement amountpayableat creation time- Changing the key's markup in the dashboard afterwards does not change a quote already issued
- The quote holds for
ttl_seconds(currently 900 seconds, i.e. 15 minutes). Read the value from the response instead of hard-coding it - Keep
quote_idand read the quote back withGET /v1/quotes/{quote_id}when the customer pays; onceexpires_athas passed the lookup returns 404, and you should create a new quote
Website demo
The live demo on the home page is for evaluation only, and it is not an API to integrate against:
- Its data is delayed by 1 hour: it prices with the latest snapshot each source had one hour ago, and shows the data time next to the result
- Every request passes an invisible human-verification check (Cloudflare Turnstile)
- It uses the platform default markup, is rate-limited per network and globally, and may return "busy" at peak times
- It does not accept API keys and is not metered against any account
For live data, create a free API key in the dashboard.
API reference
Authentication
Every /v1 endpoint requires an API key. There is no anonymous access.
| Header | Example |
|---|---|
X-API-Key |
X-API-Key: <your key> |
Authorization |
Authorization: Bearer <your key> |
- Send either one; if both are present,
X-API-Keyis used - Create and revoke keys in the dashboard. You need a verified email address first, and an account can hold up to 10 active keys. The plaintext key is shown only once
- A revoked key stops working immediately (HTTP 401
invalid_api_key) - Call the API from your server.
/v1responses carry no CORS headers, and a key embedded in a web page or mobile app can be copied by anyone GET /healthandGET /openapi.jsonneed no key
Common conventions
| Convention | Details |
|---|---|
| Rates and amounts are always strings | Not JSON numbers. A JSON number is an IEEE 754 double; a value like 0.05117904 loses precision when parsed that way and your amounts come out wrong. Parse with Decimal (Python) / BigDecimal (Java) / decimal.js (JS). Rates have 8 decimal places |
| Timestamps | Always ISO 8601 UTC strings, e.g. 2026-09-14T10:38:01Z. Where arithmetic is likely, a *_unix companion field (Unix seconds, float) is also provided |
| Percentages | Strings, expressed in % — not as fractions. "0.1791" means 0.1791%, not 17.91% |
| Currency codes | ISO 4217. Case-insensitive in requests, uppercase in responses. See Supported currencies |
| base / quote | base = the currency the price is set in, quote = the settlement currency (what the customer pays in when you charge, what you pay out in when you pay). A rate always means "how much quote one unit of base buys" |
| Caching headers | Rate responses are sent with Cache-Control: no-store. The API never sets cookies |
Rate limits and monthly quota
Each plan has a rate limit (a steady rate plus a short burst) and a monthly request quota, published on the pricing page.
- Both are counted per account, not per key. All API keys on the same account share one rate limit and one monthly quota; creating more keys does not raise either. The
X-Quota-*headers show the account-wide total - The monthly quota resets at the start of each calendar month (UTC)
- Every request that passes authentication and the limits counts toward the quota, whatever its result — including 400, 404, 422 and 503 responses. Requests rejected with 401, 403 or 429 do not count
HEADrequests to/v1endpoints are processed exactly likeGET— the full rate calculation runs — so they count toward the quota and the rate limit in the same way- Paths that don't exist return 404
not_foundand are not metered
Every /v1 response — including errors — carries these headers:
| Header | Meaning |
|---|---|
X-RateLimit-Limit |
Maximum burst: how many requests you can send back-to-back |
X-RateLimit-Remaining |
How many requests you can send right now without waiting |
X-RateLimit-Reset |
Unix time (seconds) when the next request slot becomes available |
X-Quota-Limit |
Monthly quota of the account. 0 means unlimited |
X-Quota-Used |
Requests the account has used this month, including this one |
X-Quota-Remaining |
Requests left this month. Always 0 when X-Quota-Limit is 0 (unlimited), so check the limit first |
X-Request-Id |
ID of this request. Include it when you contact support |
Retry-After |
Seconds to wait. Sent only with 429 rate_limited |
When a request can't be attributed to an account (a missing or invalid key, a disabled or unverified account, an oversized body, or an internal error), the rate and quota headers carry neutral default values that don't reflect any account.
Hitting a limit
| Response | What it means | What to do |
|---|---|---|
429 rate_limited |
Too many requests in a short time | Wait for Retry-After seconds (the body also carries retry_after), then retry |
429 quota_exceeded |
The account's monthly quota is used up. A hard block: no overage billing, no Retry-After |
Don't retry. Upgrade (upgrade_url is the absolute address of the pricing page) or wait for next month. The body carries used and quota |
Request processing
Requests are checked in this order; the first failing check determines the response:
- Body size: a request body larger than 16 KiB → 413
payload_too_large - API key missing → 401
api_key_required; invalid or revoked → 401invalid_api_key - Account disabled → 403
account_disabled; email address not verified → 403email_not_verified - Rate limit → 429
rate_limited; monthly quota → 429quota_exceeded - Parameters → 400
bad_request/ 400unsupported_currency/ 422currency_not_available(already counted toward the quota) - Pricing → 503
no_ratewhen too few valid sources; 503storage_unavailablewhen our data store is briefly unreachable
GET /v1/rate
Returns the final rate for a single pair, along with the full per-source breakdown.
Query parameters
| Parameter | Required | Details |
|---|---|---|
base |
Yes | Pricing currency. Alias from |
quote |
Yes | Settlement currency. Alias to |
strategy |
No | Which side the rate favors: max (default, charging) / min (paying) / median (neutral). Case-insensitive |
A same-currency pair (e.g. base=USD"e=USD) returns rate 1.00000000 with chosen_provider and anchor_tier set to identity.
Response: top-level fields
| Field | Type | Description |
|---|---|---|
base |
string | Pricing currency |
quote |
string | Settlement currency |
rate |
string | The final rate — this is the one you calculate amounts with. raw_rate × (1 + markup_bps/10000) when charging (side=charge), raw_rate × (1 - markup_bps/10000) when paying (side=pay). Equals raw_rate when markup_bps is 0 |
raw_rate |
string | The rate before markup. Selected across sources per strategy; already clamped to cap_rate / floor_rate if either was hit |
mid_rate |
string | Median of the valid sources, roughly the market mid. Use it to measure how far above market you are |
low_rate |
string | Lowest rate among valid sources. Equals raw_rate when strategy=min and the floor wasn't hit |
high_rate |
string | Highest rate among valid sources. Equals raw_rate when strategy=max and the cap wasn't hit |
markup_bps |
int | The markup actually applied to this call, in basis points (100 bps = 1%). Always 0 for same-currency pairs |
markup_source |
string | Where the markup came from: key_currency (this key's override for the currency) / key_default (this key's default markup) / platform (platform default) / none (same-currency pair, no markup) |
strategy |
string | The selection strategy used: max / median / min |
side |
string | Which side this call favored: charge (charging; max and median) / pay (paying; min). Markup direction and rounding follow it |
chosen_provider |
string | Which source the final rate came from, as an anonymous ID such as source_a. identity for same-currency pairs |
source_count |
int | Number of valid sources that took part in pricing (excludes dropped ones) |
spread_vs_mid_pct |
string | Deviation of the final rate from mid_rate, in %, 4 decimal places. Usually positive when charging, negative when paying |
anchor_rate |
string | The freshness anchor. Both outlier filtering and the cap and floor are measured against it; it's the median of the freshest available tier |
anchor_tier |
string | Which tier the anchor came from: realtime / hourly / daily / all / identity. Falling back to daily or all means the fresh sources are all unavailable and the quote may drift from market |
anchor_notes |
string[] | Why fresher tiers were not used as the anchor, in English. Empty array under normal conditions |
cap_rate |
string | null | The cap: anchor_rate plus a fixed band. raw_rate never exceeds it. Null for same-currency pairs |
capped |
bool | true means the selected rate exceeded the cap and was pulled back down. It signals the market is moving while slower sources lag — worth alerting on |
floor_rate |
string | null | The floor: anchor_rate minus the same band. raw_rate never falls below it. Null for same-currency pairs |
floored |
bool | true means the selected rate was below the floor and was lifted back up. Most common with strategy=min |
sources |
object[] | Full per-source breakdown, including dropped sources. See below |
generated_at |
string | When this response was generated (ISO 8601 UTC) |
generated_at_unix |
float | Same, as Unix seconds |
Response: each sources[] entry
| Field | Type | Description |
|---|---|---|
provider |
string | Stable anonymous source ID, e.g. source_a |
label |
string | Display name, e.g. Source A |
data_freq |
string | null | How often the source's own data updates: realtime / hourly / daily |
rate |
string | null | The base→quote rate this source reported. null means the source doesn't carry this pair |
vs_anchor_pct |
string | null | How far this source deviates from anchor_rate, in % (positive = higher) |
fetched_at |
string | When we fetched it (ISO 8601 UTC) |
age_seconds |
float | Seconds since that fetch |
source_time |
string | null | When the source itself claims the data is from. null = the source doesn't publish this |
used |
bool | Whether it took part in this pricing |
chosen |
bool | Whether this is the source that was selected |
excluded_code |
string | null | Machine-readable reason it was dropped; null if it was used. Match on this in monitoring |
excluded_reason |
string | null | Human-readable English reason; null if it was used. The wording may change — don't parse it |
Values of excluded_code
excluded_code |
Example excluded_reason |
Meaning | What to do |
|---|---|---|---|
missing_currency |
Source does not cover this currency pair |
The source doesn't cover this pair | Normal — other sources cover for it |
snapshot_stale |
Snapshot is stale (9000s > 7200s) |
We haven't fetched fresh data from that source for too long | Other sources cover for it; if it persists, check the status page |
deviates_from_anchor |
Deviates 12.5% from the realtime tier anchor, beyond +/-5%; treated as a data fault |
The source's value is too far from the anchor | That source's data is likely corrupt; nothing to do on your side |
GET /v1/rates
A rate table for many pairs in one call. Rows are a trimmed-down version of GET /v1/rate and omit the sources breakdown.
Query parameters
| Parameter | Required | Details |
|---|---|---|
base |
No | Comma-separated pricing currencies, e.g. USD,EUR. Empty items and duplicates are ignored |
quote |
No | Comma-separated settlement currencies, e.g. JPY,HKD |
When a parameter is omitted, the service's default currency grid is used; pass both explicitly if your integration depends on the exact rows.
If a list contains an unknown code the whole request returns 400 unsupported_currency; if it contains a currency without real-time data, 422 currency_not_available. The whole base list is checked before quote: within one list an unknown code takes precedence over a currency without real-time data, but a problem in base is always reported first, so a currency without real-time data in base combined with quote=ZZZ returns 422.
Rows use the platform default markup, not your key's markup. Use
GET /v1/rateorGET /v1/convertwhen you need your own markup applied.
Response
| Field | Type | Description |
|---|---|---|
rows |
object[] | One row per base × quote pair |
generated_at / generated_at_unix |
string / float | Generation time |
Each rows[] entry
| Field | Type | Description |
|---|---|---|
base / quote |
string | The currency pair |
final_rate |
string | Final rate with the platform default markup |
raw_rate |
string | Before markup |
mid_rate |
string | Median across sources |
chosen_provider |
string | The selected source (anonymous ID) |
source_count |
int | Number of valid sources |
markup_bps |
int | Markup applied (platform default) |
spread_pct |
string | Premium over the mid, in %, 3 decimal places |
anchor_tier |
string | Anchor tier |
capped |
bool | Whether the cap was hit |
error |
string | null | When not null, that row has no rate: only base, quote and error are present. Typical cause: too few valid sources for that pair. Always null-check before using a row |
GET /v1/convert
Converts an amount in the pricing currency into the settlement amount. Amounts round to the currency's smallest unit (JPY to 1; USD, EUR, HKD and CNY to 0.01), and the rounding direction follows side: up when charging, down when paying, so the fraction always favors you.
Query parameters
| Parameter | Required | Details |
|---|---|---|
base / quote |
Yes | Same as GET /v1/rate, including the aliases from / to |
amount |
Yes | Amount in the pricing currency as a decimal string, must be > 0. The converted amounts (payable, mid_payable), rounded to the settlement currency's smallest unit, may have at most 28 digits including decimal places; a larger amount returns 400 bad_request |
strategy |
No | Same as GET /v1/rate |
Response
| Field | Type | Description |
|---|---|---|
base / quote |
string | The currency pair |
amount |
string | The amount you passed in, as a decimal string |
payable |
string | The settlement amount. When charging (side=charge) it is what the customer owes, rounded up; when paying (side=pay) it is what you pay out, rounded down |
rate |
string | The final rate used |
mid_rate |
string | Market mid |
mid_payable |
string | The amount at the mid, rounded half-up, for comparison |
gain_vs_mid |
string | The difference in your favor — larger is better. Charging: payable - mid_payable (extra collected). Paying: mid_payable - payable (saved on payout) |
markup_bps |
int | Markup applied |
markup_source |
string | Where the markup came from, same as GET /v1/rate |
side |
string | charge / pay, same as GET /v1/rate |
chosen_provider |
string | Which source the rate came from (anonymous ID) |
source_count |
int | Number of valid sources |
anchor_tier |
string | Anchor tier |
capped |
bool | Whether the cap was hit |
floored |
bool | Whether the floor was hit |
GET /v1/convertonly converts — it does not lock the price. UsePOST /v1/quotesfor real orders.
POST /v1/quotes
Creates a locked quote: the rate, the markup and payable hold for ttl_seconds, and settlement uses payable.
Request body (JSON object, Content-Type: application/json, at most 16 KiB)
| Field | Required | Details |
|---|---|---|
base / quote |
Yes | The currency pair. Aliases from / to |
amount |
Yes | Amount in the pricing currency, must be > 0. A decimal string is recommended; a JSON number is also accepted |
reference |
No | Your own order identifier (at most 128 characters), stored and echoed back for reconciliation |
strategy |
No | Which side the rate favors, same as GET /v1/rate |
A body that isn't valid JSON, isn't a JSON object, or has a field of the wrong type returns 400 bad_request.
Response (HTTP 201)
| Field | Type | Description |
|---|---|---|
quote_id |
string | Quote ID (32 lowercase hex characters). Store it for later lookups and reconciliation |
reference |
string | null | Echoes the order identifier you passed |
base / quote |
string | The currency pair |
amount |
string | Amount in the pricing currency |
payable |
string | The settlement amount — the locked value. It will not change before expiry. Rounding follows side |
rate |
string | The locked final rate |
raw_rate |
string | The rate before markup |
mid_rate |
string | Market mid at the time of the order, for reconciliation |
markup_bps |
int | Markup locked in at creation. Changing the markup in the dashboard later does not affect this quote |
strategy |
string | Selection strategy |
side |
string | charge / pay, same as GET /v1/rate |
chosen_provider |
string | Which source the rate came from (anonymous ID) — your evidence in a dispute or reconciliation |
source_count |
int | Number of valid sources at the time |
created_at / created_at_unix |
string / float | Creation time |
expires_at / expires_at_unix |
string / float | Expiry time. After it passes, lookups by that ID return 404 |
ttl_seconds |
int | Lock duration in seconds |
If the quote cannot be stored, the response is 503 storage_unavailable and no quote exists — retry the request.
GET /v1/quotes/{quote_id}
Reads a quote back. The fields are identical to those returned at creation, plus one:
| Field | Type | Description |
|---|---|---|
expired |
bool | Whether it has expired. Quotes are removed as soon as they expire, so you'll normally get a 404 instead; this field can only be true in the narrow window before removal |
quote_idthat isn't 32 lowercase hex characters, or a quote that doesn't exist or has expired → 404not_found- 503
storage_unavailablemeans we couldn't check right now — it does not mean the quote expired. Retry
GET /v1/sources
Which sources currently feed your rates, and how fresh each one is. Use it to judge how much to trust a given rate.
Response
| Field | Type | Description |
|---|---|---|
sources |
object[] | The sources currently enabled |
source_count |
int | Number of sources |
last_poll_at |
string | null | When we last fetched (ISO 8601 UTC) |
Each sources[] entry
| Field | Type | Description |
|---|---|---|
provider |
string | Anonymous source ID; matches sources[].provider in GET /v1/rate |
label |
string | Display name of the source |
data_freq |
string | How often the source's own data updates: realtime / hourly / daily |
last_fetch_at |
string | null | When we last fetched from that source |
last_fetch_age |
float | null | Seconds since the last fetch |
source_time |
string | null | When the source itself claims the data is from. null = the source doesn't publish this |
source_age_seconds |
float | null | Age of the source's data in seconds |
currencies |
int | How many of the supported currencies the source currently covers |
These two timestamps mean very different things.
last_fetch_atis when we pulled the data, so it's always recent;source_timeis when the source itself says the data is from. Daily sources typically lag 12–36 hours, and some daily sources can exceed 80 hours over a weekend — that's normal. Judging freshness fromlast_fetch_atalone will mislead you badly.
GET /health
Service health check, no API key required. For your monitoring or load balancer.
| Field | Type | Meaning |
|---|---|---|
status |
string | ok / degraded. Reflects only "can we quote a rate right now" |
sources_healthy |
int | How many sources currently have fresh, usable data |
last_updated_at |
string | null | When the most recent source data was fetched (ISO 8601 UTC) |
HTTP status: 200 with ok, 503 with degraded.
This endpoint deliberately returns a single health signal and nothing about our internal polling state or upstream error detail. For per-source freshness use
GET /v1/sources, or visit the status page.
GET /openapi.json
The OpenAPI 3.1 document for every endpoint above, at https://api.crestrate.com/openapi.json. No API key required; it can be fetched from a browser (CORS allowed) and is cached for 5 minutes. It declares both authentication methods (X-API-Key and Bearer), so SDK generators can use it as is.
https://crestrate.com/openapi.json on the website answers with an HTTP 302 redirect to this address. Point tools at the API address directly.
Errors
Errors have the same shape across every endpoint:
| Field | Type | Description |
|---|---|---|
error |
string | Machine-readable error code. Match on this, not on message |
message |
string | Human-readable English description. The wording may change |
currency |
string | The offending currency code (unsupported_currency, currency_not_available) |
supported |
string[] | Currencies with real-time data (currency_not_available) |
retry_after |
number | Seconds to wait (rate_limited) |
used / quota |
int | Requests used this month / monthly quota (quota_exceeded) |
upgrade_url |
string | Absolute URL of the pricing page (quota_exceeded) |
| HTTP | error |
Meaning | Retry? |
|---|---|---|---|
| 400 | bad_request |
Missing or invalid parameters: no base/quote, amount missing, ≤ 0, not a number or too large, unknown strategy, invalid JSON body, reference too long |
No — fix the request |
| 400 | unsupported_currency |
Unknown currency code. Carries currency |
No |
| 401 | api_key_required |
No API key was sent | No — send X-API-Key or Authorization: Bearer |
| 401 | invalid_api_key |
The key is invalid or has been revoked | No |
| 403 | account_disabled |
The account has been disabled. Contact support | No |
| 403 | email_not_verified |
The account's email address is not verified yet | After verifying |
| 404 | not_found |
Quote not found or expired; unknown path | No |
| 413 | payload_too_large |
Request body larger than 16 KiB | No |
| 422 | currency_not_available |
Real-time data for this currency is temporarily unavailable. Carries currency and supported |
Not until the currency opens |
| 429 | rate_limited |
Rate limit exceeded. Retry-After header and retry_after field |
Yes, after Retry-After |
| 429 | quota_exceeded |
Monthly quota used up. Carries used, quota, upgrade_url |
No — upgrade or wait for next month |
| 500 | internal_error |
Unexpected server-side error | Yes, with backoff; quote X-Request-Id to support if it persists |
| 503 | no_rate |
Not enough valid sources to produce a rate we trust. message lists why each source was excluded (anonymous IDs only) |
Yes, later; fall back to your own rate meanwhile |
| 503 | storage_unavailable |
Service temporarily unavailable; nothing was stored | Yes, with backoff |
Example:
{
"error": "currency_not_available",
"message": "Real-time data for GBP is temporarily unavailable. Supported: USD, JPY, EUR, HKD, CNY.",
"currency": "GBP",
"supported": ["USD", "JPY", "EUR", "HKD", "CNY"]
}
503 no_rate is deliberate — we would rather return no price than one whose provenance is questionable. Callers should retry later or fall back to their own backstop rate. Do not treat it as 0.
Best practices
Pick the right side with strategy. Use the default max when collecting from customers, min when paying sellers or suppliers, and median for bookkeeping only. Pick the wrong one and rate selection, markup and rounding all work for the other party.
Parse rates with a decimal type. Never use floating point. Every rate and amount is a JSON string
for exactly this reason. Parsed as an IEEE-754 double, 0.05117904 loses precision and your totals drift over time.
Use Decimal / BigDecimal / decimal.js / bcmath.
Always lock the price before collecting. Time passes between the moment the customer sees a price and the moment they pay, and the rate moves in that gap.
POST /v1/quotes freezes the rate, the markup and the amount until the TTL runs out and hands you a quote_id for later reconciliation.
GET /v1/convert is for display and estimates only — don't place orders with it.
rate_limited is worth retrying; quota_exceeded isn't. The first is transient — back off per Retry-After
and try again. The second is a hard block for the rest of the calendar month, shared by every key on your account.
Upgrade, or wait for the reset next month.
Never treat 503 no_rate as 0. It means "too few valid sources — we won't give you a price we can stand behind."
Fall back to your own backstop rate, or pause the ordering flow. Do not multiply by 0.
Validate currencies on your side. A request with a currency that has no real-time data still counts toward your quota; check against the supported list before calling.
Cache on your side. Rates can't change faster than our fastest source. Caching for 30–60 seconds cuts your usage dramatically at almost no cost in accuracy.
Watch capped, floored and anchor_tier. capped: true or floored: true means the market is moving while the slower sources haven't caught up,
and we clamped the quote back toward the live market. An anchor_tier that drops to daily or all means our freshest sources are temporarily unavailable —
rates are still usable, but less fresh than usual.
Monitor on error and excluded_code, not on text. Messages and excluded_reason are for humans and may be reworded.
Versioning policy
This is v1, and the version is part of the path (/v1). We add fields without notice, so parse defensively and ignore keys you don't recognize.
We will not remove or repurpose an existing field, change a field's type, or change the meaning or HTTP status of an error code with less than 90 days' notice. Notice goes to the account email of every customer with recent traffic.
Support
Questions, or found behavior that doesn't match these docs? Email [email protected] and include the X-Request-Id of the request.