Description
Purpose and use
FX manages exchange rates, rate providers, spreads, conversion behavior, and FX-related tariffs. It supports transfers, portfolio valuation, revaluation, crypto pricing, treasury reporting, and customer-facing conversion disclosures.
Who uses this. Treasury, finance controllers, payment operations, product teams, and support teams use FX data when pricing, explaining, or reconciling cross-currency activity.
How it works. Enabled providers import rates on schedules or intervals. FX services select the right source and rate type, apply configured spread or tariff behavior, and store rate history for audit and reporting.
What users do. Users review rates, import provider data, configure spread and tariff rules, check conversion outcomes, and investigate missing or stale exchange rates.
Outcomes and side effects. FX rates influence transfer economics, quote values, LCY balance reporting, fee calculation, and revaluation journals. Rate changes do not move money by themselves.
Related manuals: Currencies, Transfers, Tariffs, Ops.
Overview
The FX package provides functionality for managing currency exchange rates, FX tariffs, and fee structures. It supports multiple exchange rate sources (Swiss National Bank, European Bank, Currency Cloud, CoinMarketCap, CRP, Open Exchange Rates) and includes a comprehensive tariff management system.
Key Features
- Multiple rate providers - SNB, EB, CC, CoinMarketCap, CRP, and Open Exchange Rates integrations
- Dual scheduling modes - Time-based (daily) and interval-based (frequent) updates
- Crypto support - CoinMarketCap as the primary scheduled crypto feed, with CRP available as a config-driven rollback option
- Runtime configuration - Dynamic interval adjustments without restarts
- Comprehensive tariff system - Fee structures and customer-specific rates
Exchange Rate Sources
Swiss National Bank (SNB)
The SNB integration fetches official exchange rates from the Swiss National Bank. These rates use CHF (Swiss Franc) as the base currency.
Features:
- Automatic daily rate updates
- Support for all major currencies against CHF
- Both direct (CHF to foreign currency) and inverse (foreign currency to CHF) rates
- BID and ASK rates calculation
Implementation Details:
- Uses the SNB data API (
https://data.snb.ch/api/cube/DEVKUM/data/json) - Rates are cached for 24 hours to minimize API calls
- Automatically retries on transient failures
- Maintains a history of rates for historical conversion needs
European Bank (EB)
Official rates from the European Central Bank in EUR. Imported rows use source = ECB on currency_pairs (legacy display name European Central Bank is mapped at query time).
Open Exchange Rates (OER)
Open Exchange Rates imports fiat market rates through https://openexchangerates.org/api/latest.json. Imported rows use source = OER on currency_pairs.
oerships enabled by default in sample config. It is safe to leave enabled withoutfx.providers.oer.app_idconfigured — the scheduler gate skips OER (no fetch, no error) until anapp_idis set (value_kind=secret; resolved viaconfig.GetSecret, e.g. SSM/{env}/fx.providers.oer.app_idwhenSECRET_DRIVER=aws, orFX_OER_APP_IDenv fallback). Configuringapp_idis what actually activates it.
Features:
- USD default base - Leaves the OER
basequery parameter empty unlessfx.providers.oer.baseis configured. - Optional base override - Supports configured base currencies such as
CHFwhen the OER plan allows non-USD bases. - MID-only persistence - Stores validated market
MIDrows and lets existing FX consumers deriveBUY/SELL. - Inverse and cross-pair generation - Persists direct, inverse, and cross-currency pairs for active currencies returned by OER.
- Retry/backoff controls - Uses config-driven timeout, retry count, and retry delay values.
- Safe scheduler gate - Startup, scheduler, and OPS imports skip OER when it is enabled without an
app_id.
Scheduling Configuration:
oer:
enabled: true # ships enabled; safe no-op until app_id is configured (see scheduler gate above)
schedule_mode: time
api_base_url: https://openexchangerates.org/api
app_id: ${FX_OER_APP_ID:-}
base: "" # optional; omit to use OER's default USD base
timeout_seconds: 30
max_retries: 3
retry_delay_seconds: 300
prettyprint: true
show_alternative: true
update_hour: 3
update_minute: 0Currency Cloud (CC)
Commercial rates from Currency Cloud with more frequent updates. Persisted currency_pairs.source is CC.
CoinMarketCap (CMC)
CoinMarketCap is the primary scheduled crypto-price provider for FX. It resolves active crypto assets to canonical CoinMarketCap IDs first and then fetches latest quotes against a configured fiat currency (typically EUR).
cmcships enabled in mock mode in sample config (enabled: true,mock: true): the scheduler derives deterministic crypto MID pairs fromfx.default_rateswith no external HTTP call and no API key required. For live CoinMarketCap rates, setfx.providers.cmc.mock=falseand configurefx.providers.cmc.api_key.
Features:
- Canonical ID resolution - Resolves symbols through
/v1/cryptocurrency/mapbefore requesting quotes - Conservative ambiguity handling - Prefers the unique top-ranked canonical asset when CoinMarketCap returns low-ranked bridge wrappers for the same symbol, while leaving genuinely ambiguous collisions unresolved
- MID-only persistence - Stores validated market
MIDrows and lets existing FX consumers deriveBUY/SELL - Inverse pair generation - Automatically persists both
CRYPTO/FIATandFIAT/CRYPTOpairs - Mock-mode support - When
fx.mockorfx.providers.cmc.mockis enabled, derives deterministic CoinMarketCapMIDpairs fromfx.default_ratesand skips external HTTP/API-key requirements - Network-aware symbol normalization - Maps currencies such as
USDCERC20,USDCTRC20,USDCMATIC, andUSDCBASEto the canonicalUSDCquote symbol while preserving the original currency codes in stored pairs - Compression-aware HTTP - Accepts CoinMarketCap's recommended compressed responses and transparently decodes
gzip/deflatepayloads before JSON parsing - Exponential retry/backoff - Retries transient
429and5xxupstream failures with config-driven retry bounds and exponential delays - Append-only history - Partial quote gaps never delete previous rows, preserving last known good prices
Implementation Details:
- Uses backend-only CoinMarketCap Pro API authentication with
X-CMC_PRO_API_KEY - Fetches canonical asset IDs from
/v1/cryptocurrency/map - Supports optional
currencies.metadata.cmc_id/currencies.metadata.cmc_slugoverrides for deterministic symbol-to-asset mapping when operators want to pin a specific CoinMarketCap asset - Fetches latest quotes from
/v3/cryptocurrency/quotes/latest - Persists
MIDpairs with sourceCMC - Supports interval-based scheduling to match CoinMarketCap's minute-level market updates
Scheduling Configuration:
cmc:
enabled: true # sample ships enabled in mock mode; a live setup needs mock:false + api_key
mock: true # deterministic local MID pairs from fx.default_rates, no external HTTP/API key
schedule_mode: interval
api_base_url: https://pro-api.coinmarketcap.com
api_key: ${FX_CMC_API_KEY:-}
convert_currency: EUR
timeout_seconds: 15
max_retries: 2
retry_delay_seconds: 2
update_interval_minutes: 1CRP (Crypto Payment Provider)
The CRP integration fetches indicative exchange rates from the CRP service via gRPC. These rates support both crypto-to-fiat and fiat-to-crypto currency pairs and remain available as a config-driven rollback path.
Features:
- Bidirectional rate support - Fetches both crypto-to-fiat and fiat-to-crypto rates
- Interval-based scheduling - Supports frequent updates for volatile crypto markets
- Flexible update intervals - Configurable in seconds, minutes, or hours
- Automatic ticker deduplication - Handles multiple networks for same token (e.g., USDC on ERC20, TRC20, MATIC)
- Comprehensive coverage - Maximum rate pairs by requesting both directions
- Uses currency seed data from the database
- Stores MID rates only (as provided by CRP service)
- Handles both crypto and fiat currencies from database
Implementation Details:
- Uses gRPC client for communication with CRP service
- Default interval mode - Updates every 30 minutes (configurable)
- Smart ticker extraction - Deduplicates multi-network tokens automatically
- Dual API calls - Requests both crypto→fiat and fiat→crypto rates
- Extracts clean crypto tickers from database (e.g., "USDCERC20" → "USDC")
- Supports metadata.ticker field for custom ticker mapping
- CRP service returns only MID rates (mid-market rates)
- Rates are cached between updates to minimize API calls
- Automatically processes all active currencies from the database
- Maintains a history of rates for historical conversion needs
Scheduling Configuration:
# Crypto rates update every 30 minutes
crp:
enabled: true
schedule_mode: interval
update_interval_minutes: 30Bidirectional Rate Fetching:
CRP service supports both directions of currency conversion, providing comprehensive coverage:
API Request 1 (Crypto → Fiat):
{
"from_currencies": ["BTC", "USDC", "ETH"],
"to_currencies": ["USD", "EUR", "CHF"]
}API Request 2 (Fiat → Crypto):
{
"from_currencies": ["USD", "EUR", "CHF"],
"to_currencies": ["BTC", "USDC", "ETH"]
}Combined Response Processing:
Received 9 crypto-to-fiat rates from CRP
Received 9 fiat-to-crypto rates from CRP
Total rates received from CRP: 18
Processed 18 CRP rates: 9 crypto-to-fiat, 9 fiat-to-crypto, 0 skippedTicker Deduplication Example:
When you have multiple networks for the same token in your database:
Database currencies:
- USDCERC20 (metadata.ticker: "USDC") # Ethereum network
- USDCTRC20 (metadata.ticker: "USDC") # Tron network
- USDCMATIC (metadata.ticker: "USDC") # Polygon network
- USDCBASE (metadata.ticker: "USDC") # Base network
- BTC (metadata.ticker: "BTC") # BitcoinBefore deduplication:
Clean crypto tickers: [USDC, USDC, USDC, USDC, BTC] # 5 requestsAfter deduplication:
Unique crypto tickers: [USDC, BTC] # 2 requests
Skipped duplicate crypto ticker 'USDC' from currency 'USDCTRC20'
Skipped duplicate crypto ticker 'USDC' from currency 'USDCMATIC'
Skipped duplicate crypto ticker 'USDC' from currency 'USDCBASE'API Endpoints
Exchange Rates
GET /v1/fx/rates
Get available exchange rates for a specific date.
Query Parameters
Filters take the search. form. A bare ?base=EUR is silently ignored — this route's
parser only recognises the keys below and drops everything else, so the request returns 200 with
the first page of rates for all pairs while the caller believes the result is filtered.
search.base_currency- Base currency code (e.g.,EUR)search.target_currency- Target currency code (e.g.,USD)search.date- Rate business/value date (YYYY-MM-DD). When omitted, FX anchors lookup to the current business date in the configured timezone.search.type- Rate type (BUY/SELL/MID)search.source,search.rate,search.rate_at,search.imported_at- the remaining filterable fieldssort- Comma-separated sort fields such as-date,-imported_at,source, drawn from the eight names above. Anything else is400 query_m.invalid_sort_field. Empty or absent falls back todefaultRatesSort, which is-imported_at, and theimported_attie-breaker is then not appended a second time — so the default order isimported_atdescending alone. A sort that namesrate_atis ordered byCOALESCE(rate_at, date at midnight UTC), the value the response actually carries.limit- Default 10, capped at 100. A non-positive or unparsable value is ignored, not rejected.offset- Default 0. A negative or unparsable value is ignored.stack- Group the rows;datathen comes back as an object keyed by the stacked value. Its field set is narrower than the filterable one:StackableFieldsholds onlybase_currency,target_currency,date,type,sourceandrate, so?stack=rate_atand?stack=imported_atare400 common.invalid_inputwithInvalid stack fieldalthough both are valid to filter and sort on. A leading-and a[format]suffix are accepted.
Two rewrites happen to a filter value before it reaches SQL, and neither raises an error. A
string value loses every character that is not a letter, a digit or one of . / @ - _
and space, so ?search.source=SNB* filters on SNB and answers 200. A value on a column the
field map types as date — here search.date, and on the /v2 lists the created_at,
modified_at, effective_from, effective_to, date_start and date_end filters — is truncated
to YYYY-MM-DD at midnight UTC, so a time component is discarded silently. search.rate_at and
search.imported_at are typed datetime and keep theirs.
date is a value date, not an intraday timestamp. When you need the real import order for multiple rates from the same day, sort by imported_at (or include it as a tie-breaker after date).
When FX lookback is enabled, the response date shows the actual resolved business date of the returned rate, which can be earlier than the originally requested or defaulted anchor date.
rate_at is the original provider quote timestamp when the upstream source exposes it. For day-level providers that do not expose an intraday quote timestamp, the API returns the start of the business day in UTC (00:00:00.000Z) derived from the rate date, so the field is always present and never null.
rate_at and imported_at are serialized as UTC RFC3339 / ISO 8601 timestamps in API responses. rate_at uses millisecond precision (for example, 2026-04-23T13:58:44.000Z).
Response
{
"data": [
{
"base_currency": "EUR",
"target_currency": "USD",
"rate": "1.0923",
"date": "2024-03-20T00:00:00.000Z",
"type": "SELL",
"source": "ECB",
"rate_at": "2024-03-20T00:00:00.000Z",
"imported_at": "2024-03-20T10:00:00Z"
},
{
"base_currency": "EUR",
"target_currency": "USD",
"rate": "1.1023",
"date": "2024-03-20T00:00:00.000Z",
"type": "BUY",
"source": "ECB",
"rate_at": "2024-03-20T00:00:00.000Z",
"imported_at": "2024-03-20T10:00:10Z"
},
{
"base_currency": "EUR",
"target_currency": "USD",
"rate": "1.0973",
"date": "2024-03-20T00:00:00.000Z",
"type": "MID",
"source": "ECB",
"rate_at": "2024-03-20T00:00:00.000Z",
"imported_at": "2024-03-20T10:00:20Z"
}
],
"total": 3,
"has_more": false
}The envelope is fx.RatesResponse — data, total, has_more and an optional metadata —
not the shared list envelope: there is no total_unfiltered and no keys. With stack set,
data is an object keyed by the stacked value instead of an array.
GET /v1/fx/rates/convert/{customer_id}
Convert amount between currencies with applied tariffs using customer's FX tariff.
:::note Legacy endpoint
V1 returns monetary amounts (amount, total_fee, fee fields) as plain numbers in major units. For minor-unit CcyAmtWithPrecision responses, use the V2 endpoint instead.
:::
Query Parameters
amount- Amount to convert (numeric, in major units, required)base- Base currency code (e.g., "EUR", required)target- Target currency code (e.g., "USD", required)type- Rate type (BUY/SELL/MID, required)indicative— return the amount without fees. Not a boolean: the handler compares the raw query value against the literal string"true", so1,TRUEandyesall read as false without any error.
base, target and type are all mandatory on this route and there is no AppConfig fallback
here: getAndValidateParams rejects an empty value for any of the three with 400
common.invalid_input. The applyDefaultCurrency fallback is called only from
resolveConvertV2Defaults, i.e. the v2 handler.
Response
{
"currency": "USD",
"amount": 109.23,
"total_fee": 1.50,
"fee_range": {
"id": "uuid",
"fx_tariff_id": "uuid",
"min_range": 0,
"max_range": 1000.00,
"fixed_fee": 1.50,
"percent_fee": 0.005,
"min_fee": 1.00,
"max_fee": 10.00,
"method": "greater",
"date_start": "2024-01-01T00:00:00Z",
"date_end": "2024-12-31T23:59:59Z"
},
"tariff": {
"id": "uuid",
"name": "Standard FX Tariff",
"description": "Standard foreign exchange tariff for retail customers",
"active": true
},
"currency_pair": {
"id": "uuid",
"base_currency": "EUR",
"target_currency": "USD",
"rate": 1.0923,
"type": "SELL",
"date": "2024-03-20T00:00:00.000Z",
"source": "ECB",
"rate_at": "2024-03-20T00:00:00.000Z",
"imported_at": "2024-03-20T10:00:00Z"
},
"spread_rule_id": "uuid",
"applied_rule_ids": ["uuid1", "uuid2"],
"fallback_tariff_used": false,
"fallback_tariff_id": null
}GET /v2/fx/rates/convert
Convert an amount between currencies. Only amount is strictly required — all other parameters fall back to operator-configured defaults in AppConfig when omitted. When customer_id is also absent, the endpoint resolves a system default tariff automatically. See AppConfig defaults and Tariff fallback precedence below.
Query Parameters
amount- Amount to convert (required). Minor units by default; useamount_unit=majorfor decimal.base- Base currency code (e.g., "EUR"). Falls back toconvert.default_baseAppConfig entry when omitted.target- Target currency code (e.g., "USD"). Falls back toconvert.default_targetAppConfig entry when omitted.customer_id- Customer UUID. When provided, the customer's assigned FX tariff is used as a fallback if no tariff is resolved from earlier steps.fx_tariff_id- FX tariff UUID. See Tariff fallback precedence.type- Transaction type for fee-range matching (BUY/SELL). Falls back toconvert.default_typeAppConfig when omitted.source- Rate source/provider (e.g., "SNB", "EB", "CMC", "CRP", "OER"). Falls back toconvert.default_sourceAppConfig when omitted.rate_type- Rate type for the exchange-rate quote lookup (BUY/SELL/MID, default: MID).date- Lookup business date in YYYY-MM-DD format. When omitted, FX anchors lookup to the current business date in the configured timezone.indicative— return the indicative rate without fees. Not a boolean: compared against the literal string"true", so only that exact value enables it. Anything else, including1andTRUE, is silently treated as false. Default false.amount_unit- Unit of theamountparameter:minor(default) ormajor.fee_ccy- Return fees in this currency instead of the base currency. This makesfee_rangemix two currencies:convertFeeRangeToCurrencyre-denominates onlyfixed_fee,min_feeandmax_fee, leavingmin_rangeandmax_rangein the base currency, and no field says which is which. Do not compare the band against the fee whenfee_ccydiffers frombase. If a conversion fails, that field is left in the base currency too and only a server-side warning records it.
AppConfig Defaults
When a query parameter is absent, the handler reads its default from AppConfig (fx module). Configure these via the Configurator UI.
| Parameter | AppConfig path | Value format |
|---|---|---|
base | convert.default_base | Currency object {"code":"EUR","precision":2} — precision is injected into the conversion context |
target | convert.default_target | Same currency object format |
type | convert.default_type | Plain string, e.g. "BUY" |
source | convert.default_source | Plain string provider code, e.g. "OER" |
fx_tariff_id | convert.default_tariff_id | Plain UUID string |
Query parameters always take precedence over AppConfig entries.
FX Rate Lookup Policy
FX owns the business-date lookup policy used by conversion, transfer quote pricing, and executor-side system lookups.
fx:
convert:
default_rate_lookup_policy:
anchor: business_date
max_lookback_days: 0anchor: business_dateanchors missingdatevalues to the configured business timezone.max_lookback_days: 0keeps lookup strict on the anchored/requested business date.max_lookback_dayscounts prior calendar dates from that anchor date; weekend/holiday rows remain eligible when providers store them.max_lookback_days: 2is an example fallback mode that lets FX reuse the most recent stored rate found within that configured calendar-day window.- When lookback resolves an earlier row,
currency_pair.datein the response contains that actual resolved business date.
Tariff Fallback Precedence
The tariff used for fee calculation is resolved in this order:
fx_tariff_idquery param — highest priority; used directly if present.customer_id→ customer's assigned tariff — whencustomer_idis provided and the customer has an FX tariff assigned. Customer-specific pricing wins over the system default.convert.default_tariff_idAppConfig — intended as the operator-configured system default, but it does not resolve the configured UUID and cannot succeed.getAppConfigFxTariffreads the key, unmarshals it intocfgID, and then queriesWhere("name = default AND active = true")— a literal, withdefaultunquoted.DEFAULTis a reserved word in PostgreSQL, so the statement is a syntax error rather than a lookup, and the error is notErrRecordNotFound: the branch answers500 common.database_error. The sample bundle ships this key set (convert.default_tariff_id: f8dc8927-…), so on a default deployment a convert request that reaches step 3 — nofx_tariff_id, no customer tariff — fails instead of falling through to step 4. Where the key is absent or inactive the step returns early and step 4 is reached normally.- Marked default tariff — the active
fx.tariffsrow withis_default = TRUE; last-resort DB lookup when no AppConfig default UUID is set or it cannot be resolved. A partial unique index enforces at most one active default. The flag is seeded by migration on the existing default tariff row and is currently read-only via the API; tariff create/update endpoints do not acceptis_default.
When no tariff is resolved at all, the conversion proceeds without fees (spread-only). The AppConfig default is intentionally applied after the customer lookup so that customer-bearing requests always honour the customer's assigned tariff.
Response
Monetary amounts (amount, total_fee) are returned in CcyAmtWithPrecision format — minor/atomic units with currency code and precision metadata. Exchange rates remain numeric.
{
"amount": {
"amount": "10923",
"currency": "USD",
"precision": 2
},
"total_fee": {
"amount": "150",
"currency": "EUR",
"precision": 2
},
"fee_range": {
"id": "uuid",
"fx_tariff_id": "uuid",
"min_range": 0,
"max_range": 100000,
"fixed_fee": 150,
"percent_fee": 0.005,
"min_fee": 100,
"max_fee": 1000,
"method": "greater",
"date_start": "2024-01-01T00:00:00Z",
"date_end": "2024-12-31T23:59:59Z"
},
"tariff": {
"id": "uuid",
"name": "Standard FX Tariff",
"description": "Standard foreign exchange tariff for retail customers",
"active": true,
"fallback_tariff_id": "uuid"
},
"currency_pair": {
"id": "uuid",
"base_currency": "EUR",
"target_currency": "USD",
"rate": 1.0923,
"type": "SELL",
"date": "2024-03-20T00:00:00.000Z",
"source": "SNB",
"rate_at": "2024-03-20T00:00:00.000Z",
"imported_at": "2024-03-20T10:00:00Z"
},
"mid_rate": 1.0923,
"spread_rule_id": "uuid",
"applied_rule_ids": ["uuid1", "uuid2"],
"fallback_tariff_used": false,
"fallback_tariff_id": null
}| Field | Type | Description |
|---|---|---|
amount | CcyAmtWithPrecision | Converted amount in target currency (minor units) |
total_fee | CcyAmtWithPrecision | Total fee in base currency (minor units) |
mid_rate | number | Mid-market exchange rate before spread |
fee_range | object | Matched fee range in minor units of the base currency — the row exactly as stored (150 is 1.50). percent_fee is a decimal rate, not an amount: 0.005 is 0.5%. The V1 endpoint reports the same row in major units, matching its own total_fee. |
currency_pair | object | Exchange rate pair used for conversion |
:::tip Converting to major units
To convert any CcyAmtWithPrecision value to major units: major = parseInt(amount) / 10^precision.
For example, {"amount": "10923", "currency": "USD", "precision": 2} = 109.23 USD.
:::
Key Differences from v1:
- No
customer_idpath parameter required —customer_idis an optional query param;base,target,type, andfx_tariff_idall have AppConfig defaults so the endpoint can be called with onlyamount - Tariff resolved via four-step fallback chain:
fx_tariff_id→ customer tariff → AppConfig default → marked-default tariff (is_default = TRUE); see Tariff fallback precedence - Monetary amounts (
amount,total_fee) useCcyAmtWithPrecisionformat (minor units) instead of plain numbers - Supports
indicative=truemode for rate previews without fees — the value must be exactlytrue, since it is string-compared rather than parsed - Includes
applied_rule_idsarray showing all spread rules applied - Includes
fallback_tariff_usedandfallback_tariff_idwhen fallback tariff resolution occurs - Supports
sourceparameter to specify rate provider - Supports
rate_type(BUY/SELL/MID, default MID) for rate lookup, separate fromtype(transaction type for fee-range matching) - Uses the FX module's business-date lookup policy, so
currency_pair.datemay resolve to an earlier stored rate date when lookback is enabled
GET /v1/fx/rates/sources
Get a distinct list of all available rate sources in the system with their dynamically determined supported methods.
Response
[
{
"source": "SNB",
"methods": ["SELL", "BUY", "MID"]
},
{
"source": "ECB",
"methods": ["MID"]
},
{
"source": "CC",
"methods": ["SELL", "BUY"]
},
{
"source": "CMC",
"methods": ["MID"]
},
{
"source": "CRP",
"methods": ["SELL", "BUY", "MID"]
},
{
"source": "OER",
"methods": ["MID"]
}
]Note: The methods array is dynamically determined from the database based on what rate types are actually available for each source.
GET /v1/fx/convert/defaults
Read the operator-set FX conversion defaults — the same convert.default_base and convert.default_target entries described under AppConfig defaults — so a customer-facing application can present the default currency pair the operator configured instead of deriving one itself. Available to normal customer tokens (role User), not only administrators.
The endpoint exposes exactly these two whitelisted entries; no other setting of the fx configuration module is readable through it. Each entry resolves the same way the conversion endpoint resolves it: the Configurator-managed AppConfig row first (object format or a legacy plain-string currency code), then the deployment's YAML configuration. Codes are returned upper-cased. precision appears only when an administrator explicitly configured it — including an explicit 0 for zero-decimal currencies such as JPY.
A default the operator has not configured is returned as null — this is a normal answer, not an error, and a fresh installation returns both fields as null. The endpoint does not check that a configured code exists in the currency catalog, is active, or has exchange rates; the consuming application decides whether the configured pair is usable and falls back to its own selection when it is not.
Response
{
"base": {
"code": "EUR",
"precision": 2
},
"target": {
"code": "CHF",
"precision": 2
}
}With no defaults configured:
{
"base": null,
"target": null
}FX Tariffs
The FX Tariffs API provides functionality for managing foreign exchange pricing and fee calculations:
- FX Tariff management (CRUD operations)
- Fee range definitions for transaction amounts
- Spread rules for BUY/SELL rate configuration (v2)
- Multi-currency support
- Currency-pair specific fees
Core Concepts
Fee Calculation Methods
fixed: Fixed amount feepercentage: Percentage of transaction amountgreater: Greater of fixed or percentagelesser: Lesser of fixed or percentagesum: Sum of fixed and percentage
Fee Range Search Priority
When resolving which fee range to apply for a transaction, the system follows a strict priority order:
- Narrow down by date ranges - Only fee ranges where
date_startform —?search.fx_tariff_id=,?search.provider=SNB— alongsidelimit,offset,sort,filter,search_text,stackanddistinct.search.base_currencyandsearch.target_currencytake the currency id, not the ISO code the response shows:fx.spread_rulesstores both asuuidandmapSpreadRuleToResponseresolves them to a code only on the way out, so?search.base_currency=EURis400 query_m.invalid_search_value.
A bare ?fx_tariff_id= is silently ignored: the parser's switch has no case for it and
falls through to default: return nil, so the request succeeds and returns unfiltered results
rather than failing.
Response:
{
"data": [
{
"id": "uuid",
"fx_tariff_id": "uuid",
"provider": "SNB",
"base_currency": "EUR",
"target_currency": "USD",
"rate_type": "BOTH",
"bid_spread_bps": 50.0,
"ask_spread_bps": 50.0,
"effective_from": "2024-01-01T00:00:00Z",
"effective_to": "2024-12-31T23:59:59Z",
"active": true
}
],
"total": 1,
"total_unfiltered": 12,
"has_more": false
}This is the shared list envelope, models.GetAllResponseAPI — unlike GET /v1/fx/rates, which
has its own. metadata and keys carry omitempty and are absent unless the request produced
them; with stack set, data is an object keyed by the stacked value instead of an array. The
module has three routes on this envelope — this one, GET /v2/fx/tariffs and
GET /v2/fx/tariffs/fees — but only this one has a section here; the other two are documented in
fx.openapi.json alone. Every other route in this manual returns its own shape:
GET /v2/fx/spreads/{id}/history below is a bare array, because its handler marshals the slice
directly, and GET /v1/fx/rates has the module's own RatesResponse.
Get Spread Rule History
GET /v2/fx/spreads/{id}/history
Get the change history for a spread rule.
Query Parameters:
action— filter by action, matched exactly againstcreate,updateordelete. A flat parameter, notsearch.action: the handler readsr.URL.Query().Get("action")by hand and ignores an empty value.createandupdatecome from this module;deleteis written only byPATCH /v1/misc/patch(see the deactivate endpoint above).
Response:
[
{
"id": "uuid",
"spread_rule_id": "uuid",
"fx_tariff_id": "uuid",
"provider": "SNB",
"base_currency": "EUR",
"target_currency": "USD",
"rate_type": "BOTH",
"bid_spread_bps": 50.0,
"ask_spread_bps": 50.0,
"effective_from": "2024-01-01T00:00:00Z",
"effective_to": "2024-12-31T23:59:59Z",
"active": true,
"reason": "Standard spread for EUR/USD pair",
"changed_at": "timestamp",
"changed_by": "uuid",
"action": "create"
}
]Preview Spread Rates
GET /v2/fx/spreads/preview
Preview how spread rules will affect BUY/SELL rates for a given MID rate.
Query Parameters:
- fx_tariff_id: FX tariff ID (optional)
- provider: Provider name (optional)
- base_currency: Base currency code (required)
- target_currency: Target currency code (required)
- mid_rate: MID rate to use for calculation (required)
- at: Timestamp for which to resolve spreads (optional, defaults to now)
Response:
{
"mid_rate": 1.0923,
"bid_rate": 1.0868,
"ask_rate": 1.0978,
"bid_spread_bps": 50.0,
"ask_spread_bps": 50.0,
"applied_rule_ids": ["uuid1", "uuid2"],
"spread_rule_id": "uuid1"
}Note: applied_rule_ids contains all spread rule IDs that were applied during resolution. spread_rule_id is the primary rule ID (typically the first one from applied_rule_ids).
Fee Calculation Methods
fixed- Fixed fee amount (e.g., 1.50 EUR)percentage- Percentage of transaction amount (e.g., 0.5% of 100 EUR = 0.50 EUR)greater- Greater of fixed or percentage (max(fixed_fee, percent_fee))lesser- Lesser of fixed or percentage (min(fixed_fee, percent_fee))sum- Sum of fixed and percentage (fixed_fee + percent_fee)
Currency Rate Types
The system supports three types of exchange rates:
- BUY (ASK) - Rate used when a customer is buying the base currency using the target currency
- SELL (BID) - Rate used when a customer is selling the base currency to get the target currency
- MID - The midpoint rate between BUY and SELL, used for reference or calculation purposes
DSL Action Output
When the FX conversion is invoked via the DSL exchange action, the output map provides both major units (backwards-compatible) and minor units for monetary amounts. That covers the fee range's band as well as its fees: the stored row is minor units throughout, so min_range and max_range keep their long-standing major-unit meaning and the stored values are published beside them as min_range_minor and max_range_minor.
{
"amount": 109.23,
"amount_minor": 10923,
"currency": "USD",
"precision": 2,
"rate": 1.0923,
"rate_type": "SELL",
"source": "SNB",
"mid_rate": 1.0923,
"total_fee": 1.50,
"total_fee_minor": 150,
"fee_range": {
"id": "uuid",
"min_range": 0,
"min_range_minor": 0,
"max_range": 1000.00,
"max_range_minor": 100000,
"fixed_fee": 1.50,
"fixed_fee_minor": 150,
"percent_fee": 0.005,
"method": "greater",
"min_fee": 1.00,
"min_fee_minor": 100,
"max_fee": 10.00,
"max_fee_minor": 1000
},
"tariff": {
"id": "uuid",
"name": "Standard FX Tariff"
},
"spread_rule_id": "uuid",
"applied_rule_ids": ["uuid1", "uuid2"],
"fallback_tariff_used": false,
"fallback_tariff_id": "uuid"
}| Field | Type | Description |
|---|---|---|
amount | number | Converted amount in major units (e.g., 109.23) |
amount_minor | integer | Converted amount in minor/atomic units (e.g., 10923) |
currency | string | Target currency code |
precision | integer | Number of decimal places for the target currency |
rate | number | Applied exchange rate |
total_fee | number | Total fee in major units (base currency). Always present (0 when no fee). |
total_fee_minor | integer | Total fee in minor units (base currency). Always present (0 when no fee). |
fee_range | object | Matched fee range (fee values in major units). Only present when an FX tariff applies. |
:::tip DSL path references
Use $state.fx.amount_minor for minor-unit amounts and $state.fx.total_fee_minor for fees in DSL expressions. Both transfer and exchange actions provide amount / amount_minor, so you can access $state.*.amount_minor uniformly.
:::
Architecture
Service Layer
The FX package follows a service-oriented architecture with clearly separated concerns:
- Exchange Service Interface - Defines common interface for rate providers
- SNBExchangeService - Swiss National Bank implementation
- Currency Cloud Service - Currency Cloud implementation
- European Bank Service - European Central Bank implementation
- CoinMarketCapExchangeService - CoinMarketCap crypto-market data implementation
- CRPExchangeService - CRP gRPC service implementation
- OpenExchangeRatesService - Open Exchange Rates fiat-market data implementation
- FxTariffService - Manages FX tariffs
- FxTariffFeeService - Manages fee structures for FX operations
Scheduler
The FX package includes a scheduler that automatically refreshes exchange rates at configurable times. The scheduler supports two modes: time-based (specific times) and interval-based (regular intervals).
Time-Based Scheduling (Default)
Traditional scheduling where rates update at specific times each day:
# Sample time-based scheduler configuration
fx:
scheduler:
enabled: true
timezone: "Europe/Zurich" # IANA timezone format
update_hour: 1 # Default fallback hour
update_minute: 0 # Default fallback minute
providers:
snb:
# Deprecated 2026-07-22: the shipped bundle sets this false and the scheduler
# no longer fetches SNB rates. Shown enabled only to illustrate the keys.
enabled: false
schedule_mode: time # Default mode
update_hour: 2 # 02:30 daily
update_minute: 30
max_retries: 3
retry_delay_seconds: 300
eb:
enabled: true
schedule_mode: time # Default mode
update_hour: 16 # 16:00 daily
update_minute: 0
cc:
enabled: false
schedule_mode: time # Default mode
update_hour: 1 # 01:30 daily
update_minute: 30
oer:
enabled: true # ships enabled; safe no-op until app_id is configured
schedule_mode: time
api_base_url: https://openexchangerates.org/api
app_id: ${FX_OER_APP_ID:-}
base: "" # Optional OER base override; empty uses USD
timeout_seconds: 30
max_retries: 3
retry_delay_seconds: 300
prettyprint: true
show_alternative: true
update_hour: 3
update_minute: 0Interval-Based Scheduling (New)
For volatile assets like cryptocurrencies that require more frequent updates:
# Sample interval-based scheduler configuration
fx:
providers:
cmc:
enabled: true # sample ships enabled in mock mode; live setup needs mock:false + api_key
mock: true # provider-local mock switch (fx.mock also applies)
schedule_mode: interval # Enable interval mode
api_key: ${FX_CMC_API_KEY:-}
convert_currency: EUR
update_interval_minutes: 1 # Update every minute
crp:
enabled: false # Rollback option if CMC is disabled
schedule_mode: interval
update_interval_minutes: 2Supported Interval Formats
The scheduler supports multiple time units for interval-based scheduling:
- Minutes:
update_interval_minutes: 30(every 30 minutes) - Hours:
update_interval_hours: 2(every 2 hours) - Seconds:
update_interval_seconds: 300(every 5 minutes)
The three are SUMMED, not prioritised. getProviderInterval computes
hours*Hour + minutes*Minute + seconds*Second, so update_interval_hours: 2 together with
update_interval_minutes: 30 is one interval of 2h30m — not 30 minutes, and not 2 hours. The legacy
default of 30 minutes applies only when all three are zero, and the total is floored at
MinIntervalSeconds, 30 seconds.
Examples
High-frequency crypto updates (every 5 minutes):
cmc:
enabled: false # set to true after configuring api_key or mock mode
mock: false
schedule_mode: interval
update_interval_minutes: 5Moderate frequency updates (every 2 hours):
cmc:
enabled: false # set to true after configuring api_key or mock mode
mock: false
schedule_mode: interval
update_interval_hours: 2Fine-tuned updates (every 90 seconds):
cmc:
enabled: false # set to true after configuring api_key or mock mode
mock: false
schedule_mode: interval
update_interval_seconds: 90Changing an interval
There is no runtime interval API, and a config change does not take effect until restart.
Scheduler exposes only Start, Stop, FetchRatesNow and FetchSNBRatesNow;
scheduleProviderUpdateInterval reads getProviderInterval once, before starting its
goroutine, and every later timer.Reset reuses that captured value. Editing
update_interval_* through the Configurator changes the stored value and nothing else until the
process restarts.
Scheduling Modes Comparison
| Feature | Time-Based | Interval-Based |
|---|---|---|
| Use Case | Traditional forex (SNB, EB, CC) | Volatile assets (Crypto) |
| Update Frequency | Once daily at specific time | Continuous at regular intervals |
| Configuration | update_hour, update_minute | update_interval_* |
| Timezone Support | ✅ Supports timezone settings | ❌ Uses elapsed time |
| First Update | Next scheduled time | 1 minute after startup |
| Runtime Changes | ❌ Requires restart | ❌ Requires restart — the interval is captured once at scheduling time |
| Best For | Stable markets, daily updates | High-frequency, volatile markets |
Troubleshooting
Common Configuration Issues
Units add up rather than override:
# ❌ Not what it looks like - this is 2h30m, not 30m and not 2h
cmc:
update_interval_minutes: 30
update_interval_hours: 2
# ✅ Set one unit when you want one interval
cmc:
update_interval_hours: 2Mixed scheduling modes:
# ❌ Wrong - contradictory settings
cmc:
schedule_mode: interval
update_hour: 2 # Ignored in interval mode
update_interval_minutes: 30 # Used in interval mode
# ✅ Correct - consistent settings
cmc:
schedule_mode: interval
update_interval_minutes: 30Dependencies
common/errs- Error handlingcommon/auth- Authenticationcommon/logger- Logging functionalitycommon/rbac- Role-based access controlcommon/utils- Utility functionsconnectors/ram- Caching functionalitymodules/currencies- Currency managementmodules/customers- Customer management
Manual rate refresh
There are no debug HTTP endpoints. Earlier revisions of this manual documented
GET /v1/debug/fx/fetch-snb, -eb, -cc and -crp; no such routes are registered anywhere in
the process, and the {"status": "success"} body shown for them is a shape apireply never
produces — status is always an integer there.
What does exist is on the scheduler, not the HTTP surface: Scheduler.FetchRatesNow() refreshes
every provider and Scheduler.FetchSNBRatesNow() refreshes SNB only. Both are in-process calls
with no route in front of them, so an immediate refresh has to be triggered from the scheduler
rather than over the API.
Error Handling
All errors use the shared apireply.StdResponse envelope. status is an integer repeating
the HTTP status, and class is always present on a non-2xx:
{
"status": 403,
"message": "Access denied",
"code": "common.forbidden",
"class": "business"
}message is the localised i18n template for code, with only the {placeholder} tokens the
template itself contains substituted. No common.* template carries a placeholder, so the
context a handler attaches with WithParam reaches the server log and not the body — and
common.forbidden renders as "Access denied", not "Forbidden".
class is validation for 400 and 422; temporary for 408, 429, 502, 503 and 504,
which also carry retryable: true; and business for everything else — including 5xx,
deliberately, since a bug is not the caller's input to fix.
Error Codes
General FX Errors
| Code | Description |
|---|---|
| fx_m.exchange_rate_not_found | Exchange rate not found |
| fx_m.empty_default_rates | No default exchange rates configured |
| fx_m.invalid_base_currency | Invalid base currency provided |
| fx_m.invalid_target_currency | Invalid target currency provided |
| fx_m.invalid_rate | Invalid rate value |
| fx_m.invalid_type | Invalid rate type |
| fx_m.invalid_source | Invalid rate source |
| fx_m.failed_to_prepare_fetch | Failed to prepare request to provider |
| fx_m.failed_to_fetch | Failed to fetch rates from provider |
| fx_m.failed_to_read_response | Failed to read provider response |
| fx_m.currency_codes_required | Base and target currency codes are required |
| fx_m.fetching_exchange_rate_failed | Provider returned error while fetching rate |
| fx_m.failed_to_fetch_rate | Failed to fetch rate from database |
| fx_m.invalid_rate_source | Invalid rate source specified |
| common.invalid_input | Malformed body, a rejected field value, or an unparsable query parameter |
| common.record_not_found | No tariff or fee range with the given id |
| common.forbidden | Record permission denied by the service |
| common.database_error | A query, insert, update or delete failed |
| query_m.invalid_search_field | A search field the parser does not know — 500, not 400: validateFieldName builds it with no WithCode and an empty code defaults to 500 |
| query_m.invalid_field | A search field name that fails the character pattern, or a stack naming an unknown field — a real 400. It is the near-twin of the row above and answers differently |
| query_m.invalid_search_value | A bad value on a recognised field — a real 400. Only uuid-typed fields have their value checked |
| query_m.invalid_operator | An unrecognised operator suffix — a real 400 |
| query_m.invalid_sort_field | A sort field outside the route's field map — a real 400 |
| query.operator_requires_value | An operator other than eq/ne given no value — a real 400 |
These are the literal errs.MsgCode keys, which is what the code field of an error
body carries. The FX tariff and fee-range services emit only the four shared common.*
keys above — they do not have fx_m. codes of their own. Nine rows previously listed here
(fx_m.fx_tariff_not_found, fx_m.fee_range_not_found, fx_m.invalid_fee_range,
fx_m.overlapping_ranges, fx_m.invalid_calculation_method, fx_m.invalid_date_range,
fx_m.invalid_fx_tariff_data, fx_m.fx_tariff_already_exists, fx_m.insufficient_rights)
were built by prefixing names from the separate tariffs module catalog with fx_m.; none
of them exists. The real tariffs.* keys belong to modules/tariffs, not here.
On the three /v2 lists, sort and stack are validated only when a row matched.
applyParams parses them on the fetch pass, and GetAllTotal skips the fetch when the filtered
total is 0 — so ?sort=nosuchfield is 400 on a populated tenant and 200 on an empty one. A
client verified against an empty stand will meet the rejection for the first time in production.
/v1/fx/rates does not share this: getRatesSortFields runs on every request.
FX Tariff and Fee Range Errors
These services declare no fx_m. codes of their own. They reply with the shared keys only:
common.invalid_input, common.record_not_found, common.forbidden and
common.database_error — plus the 400 common.forbidden refusal described under
Get All FX Tariffs.
A 403 common.forbidden on these routes has two possible sources, and for a CRUD call on one
record the second is the likelier: the RBAC middleware's endpoint-grant check, and the service's
own per-record permission check. A caller who may call PUT /v1/fx/tariffs/fees/{id} but lacks
update on that particular range gets the same 403 — so it does not prove the API-level grant is
missing.
Refusals the middleware writes before the handler runs
These apply to every route in this module and are produced by the root router's middleware
chain — auth.RateLimitMiddleware, then health.LifecycleMiddleware, then auth.Middleware —
not by module code, which is why they are easy to overlook. That order matters twice below.
| Status | Code | Cause |
|---|---|---|
401 | common.unauthorized | No bearer token, one that does not parse, a blacklisted token, or a cache error during the blacklist lookup |
403 | common.rbac_no_rec_access → No access to the record | rbac.CanCallAPIv0 denied the endpoint grant. Forbidden403 is called with no AppError, so the body carries the helper's default code |
403 | license_m.license_invalid, license_m.license_expired, license_m.module_not_licensed | The licence branch. module_not_licensed means the tenant's licence does not cover this module: fx is not one of the six modules the loader gates, so its routes exist in the binary and are refused per request. Two further codes the middleware matches cannot reach a client: license_m.license_service_unavailable is written to the licence-error context by no code path, and license_m.license_key_missing needs licenseService == nil, a state a running process cannot be in — rbac.Init calls InitLicenseService unconditionally and routes a failure through logger.Fatalf, so a bad key stops the process at startup instead of serving 403s |
503 | {"overall_status": "unhealthy", …} — not the envelope | Graceful shutdown. health.LifecycleMiddleware is mounted with r.Use on the root router, so it precedes authentication and every handler — though not everything: auth.RateLimitMiddleware is registered two lines earlier, so a caller over its limit gets a 429 even while the server drains |
503 | auth_m.internal_server_error → Internal server error | The auth cache is unhealthy — and only when the rate limiter is off, see below |
The 503 is two different bodies, and a client has to branch on the shape rather than assume
the envelope. While the server is draining, health.LifecycleMiddleware writes a bare map —
{"overall_status": "unhealthy", "message": "Service is shutting down", "timestamp": "…"} —
with no status, code, class or retryable field on it at all, and whose message is a fixed
English string, not an i18n key, so Accept-Language does not translate it. The auth-cache 503
is the envelope, but its code is auth_m.internal_server_error, not common.server_error:
ensureCacheAvailable calls errs.New(MsgInternalServerError) unqualified from inside package
auth, so the constant that resolves is auth.MsgInternalServerError, whose value carries the
auth_m prefix. A client branching on common.server_error to detect a dead cache never matches.
And that second 503 is only observable with the rate limiter off.
auth.RateLimitMiddleware is registered before auth.Middleware and touches the same
Redis/valkey, so with rate_limits.rate_limits_switcher on a dead cache is answered by the limiter
first — 500 for an authenticated caller, 429 for an anonymous one — and ensureCacheAvailable
is never reached. That 500 is generic as well: handleRateLimitError's default branch calls
apireply.InternalServerError500(w, r) without the AppError, so
auth_m.failed_to_cache_user_limits, auth_m.failed_to_fetch_user_roles and
rate_limits_m.failed_to_increment_ip_limit are discarded and the body reads
common.server_error. Do not use the 503 as your cache-down signal in a deployment that rate
limits.
created_by and modified_by are stripped for most callers
Unless app-config auth.audit_fields_internal is explicitly false, auth.WrapWithMiddlewares
also runs RemoveAuditFieldsHandler. It triggers on the Content-Type: application/json that
apireply.WithJSON always sets, re-marshals the whole body and deletes created_by and
modified_by at every nesting depth for any caller without the internal role. The keys are then
absent, not null — and because the body round-trips through a Go map, key order is not preserved
either.
Rate limiting
auth.RateLimitMiddleware is mounted on the root router in , above every route in
the process — but only when the AppConfig flag rate_limits.rate_limits_switcher is true. With
the switch off no 429 is reachable at all. It exempts only OPTIONS and the health-check path.
The code is not common.too_many_requests. That value is only the fallback
apireply.TooManyRequests429 uses when no AppError is supplied, and handleRateLimitError always
supplies one — so it never reaches a client. Five codes are reachable, and two of them are free
text rather than an errs.MsgCode, because the call site hands a plain string to errs.New and
MachineCode() returns Key verbatim:
| Condition | code | message |
|---|---|---|
| Per-user, per-endpoint, per-window limit tripped | rate_limits_m.exceeded | Rate limit for to exceeded. |
| Global per-IP limit tripped | rate_limits_m.global_exceeded | Global rate limit for exceeded. |
| The counter increment itself failed | rate_limits_m.failed_to_increment_ip_limit | Failed to increment IP limit. |
| RBAC per-endpoint check | rate limit exceeded | rate limit exceeded |
| Anonymous caller, global IP limit | Global rate limit exceeded | Global rate limit exceeded |
The last is reachable even on a route that requires a token, because the ordering runs the
other way: auth.RateLimitMiddleware is r.Use'd on the root router in , while
auth.Middleware is applied per route by auth.WrapWithMiddlewares. A caller over the global
per-IP ceiling is answered 429 before authentication ever runs and never sees the 401. Its
code is that plain English sentence, not an errs.MsgCode.
For an authenticated caller the ceilings come from the RBAC endpoint configuration in the
database, evaluated per minute, hour, day and week — not from constants. The one hard-coded value is
the global ceiling, ratelimits.GlobalIPLimit = 1000, and on that path it is not per endpoint:
keys the global counter rate_limit:ip_global:{ip}:{limitType}, so a single
1000-request bucket per window is shared across every endpoint the caller touches from that IP. Only
the anonymous branch puts the endpoint in the key — rate_limit:ip_global:{ip}:{method}:{path} —
so an anonymous caller does get a bucket per endpoint. Budget 1000 authenticated calls per FX
endpoint and the 429 arrives well below the ceiling you expect.
Spread Rule Errors (v2)
| Code | Description |
|---|---|
| fx_m.spread_not_found | Spread rule not found |
| fx_m.spread_invalid_payload | Invalid spread rule data provided |
| fx_m.spread_overlap | Spread rules overlap in time range |
| fx_m.spread_resolver_failure | Failed to resolve applicable spread rules |
Environment Configuration
The FX module can be configured through the following YAML configuration settings:
# AppConfig defaults for /v2/fx/rates/convert
# Set these via the Configurator UI (fx module) to power a "default conversion page".
# Currency values use the object format {"code":"EUR","precision":2}.
# Leaving a value empty means callers must supply that parameter explicitly.
convert:
default_base: "" # fx/convert.default_base — e.g. {"code":"EUR","precision":2}
default_target: "" # fx/convert.default_target — e.g. {"code":"USDC","precision":6}
default_type: "" # fx/convert.default_type — e.g. "BUY"
default_source: "OER" # fx/convert.default_source — e.g. "OER", "SNB", "EB"
default_tariff_id: "" # fx/convert.default_tariff_id — UUID of the default FX tariff
default_rate_lookup_policy:
anchor: "business_date" # fx/convert.default_rate_lookup_policy.anchor
max_lookback_days: 0 # fx/convert.default_rate_lookup_policy.max_lookback_days (prior calendar dates)
# Enable/disable mock data for development
mock: true
# Provider-specific settings
providers:
snb:
# Deprecated 2026-07-22: false in the shipped bundle.
enabled: false
update_hour: 2
update_minute: 30
max_retries: 3
retry_delay_seconds: 300
eb:
enabled: true
update_hour: 16
update_minute: 0
cc:
enabled: false
update_hour: 1
update_minute: 30
cmc:
enabled: true # sample ships enabled in mock mode; a live setup needs mock:false + api_key
mock: true # derive CoinMarketCap MID pairs locally from fx.default_rates instead of calling CMC
schedule_mode: interval
api_base_url: "https://pro-api.coinmarketcap.com"
api_key: "${FX_CMC_API_KEY:-}"
convert_currency: "EUR"
timeout_seconds: 15
max_retries: 2
retry_delay_seconds: 2
update_interval_minutes: 1
crp:
enabled: false
schedule_mode: interval
update_interval_minutes: 2
oer:
enabled: true # ships enabled; safe no-op until app_id is configured
schedule_mode: time
api_base_url: "https://openexchangerates.org/api"
app_id: "${FX_OER_APP_ID:-}"
base: "" # optional; empty uses OER's default USD base
timeout_seconds: 30
max_retries: 3
retry_delay_seconds: 300
prettyprint: true
show_alternative: true
update_hour: 3
update_minute: 0
# Provider API URLs
snb_rates_url: "https://data.snb.ch/api/cube/DEVKUM/data/json"
eb_rates_url: "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml"Error Codes
| Code | Description |
|---|---|
| fx_m.exchange_rate_not_found | Exchange rate not found |
| fx_m.empty_default_rates | No default exchange rates configured |
| fx_m.invalid_base_currency | Invalid base currency provided |
| fx_m.invalid_target_currency | Invalid target currency provided |
| fx_m.invalid_rate | Invalid rate value |
| fx_m.invalid_type | Invalid rate type |
| fx_m.invalid_source | Invalid rate source |
| fx_m.failed_to_prepare_fetch | Failed to prepare request to provider |
| fx_m.failed_to_fetch | Failed to fetch rates from provider |
| fx_m.failed_to_read_response | Failed to read provider response |
| fx_m.currency_codes_required | Base and target currency codes are required |
| fx_m.fetching_exchange_rate_failed | Provider returned error while fetching rate |
| fx_m.failed_to_fetch_rate | Failed to fetch rate from database |
| fx_m.invalid_rate_source | Invalid rate source specified |