CorebanqCorebanq Developer Docs
FXv1

Retrieve exchange rates

Lists stored rate rows. This route does NOT use the shared query parser and does NOT use the shared list envelope — it has its own parseQueryParams and its own RatesResponse. Pagination is silently clamped rather than validated: limit defaults to 10, is capped at 100, and a non-numeric or non-positive value is IGNORED (the default is used) instead of answering 400. offset defaults to 0 and a negative or non-numeric value is likewise ignored. Results are limited to the currencies the caller is permitted to read; when none are permitted the reply is an empty data set with total 0, not a 403. rate is a quoted decimal STRING in this response — see BaseCurrencyPair. SEARCH FILTERS. Every search.* parameter below is one filter on one column, and the list is the whole set the route accepts. Append an operator to compare instead of match — search.{field}.{op}, op one of eq, ne, gt, gte, lt, lte, in, nin, like, start_with, end_with; with no suffix the operator is eq. in and nin take a comma-separated list. like, start_with and end_with are case-insensitive ILIKE. search.{field}.eq=null and search.{field}.ne=null test for NULL; any other operator with an empty value is 400 query.operator_requires_value. A STRING VALUE IS SILENTLY REWRITTEN BEFORE IT REACHES THE QUERY. sanitizeStringValue drops every character that is not a letter, a digit, or one of . / @ - _ and space, and it never reports an error — so search.provider=A*B filters on AB and answers 200, not 400. A value that parses as a timestamp is passed through instead of stripped. Which rejections are 400 and which are 500 is set out under the 400 and 500 responses; the split is not the one a caller would expect. A DATE-TYPED FILTER IS TRUNCATED TO THE DAY. Where the route's field map types a column as date, normalizeSearchValueByFieldType rewrites the value as YYYY-MM-DD at midnight UTC before it reaches SQL, so a time component on such a filter is discarded without a word. Those parameters carry format: date below; the format: date-time ones are compared as sent.

GET
/v1/fx/rates

Authorization

bearerAuth
AuthorizationBearer <token>

In: header

Query Parameters

limit?integer

Maximum rows. Default 10, maximum 100. A value that is not a positive integer is ignored, not rejected.

offset?integer

Rows to skip. Default 0. A negative or unparsable value is ignored.

sort?string

Comma-separated sort expression over the eight searchable fields, each optionally prefixed with - for descending. A name outside that set is 400 query_m.invalid_sort_field. Empty or absent falls back to defaultRatesSort, which is -imported_at; appendRatesImportedAtTieBreaker then finds imported_at already present and adds nothing, so the default order is imported_at descending alone. A sort naming rate_at is ordered by COALESCE(rate_at, date at midnight UTC) — the value the response carries, since the column is nullable and the marshaller falls back to the date.

stack?string

Group the rows by this field; data then comes back as an object keyed by the stacked value. Only the six StackableFields names are accepted — base_currency, target_currency, date, type, source, rate — optionally with a leading - for descending order and a [format] suffix. rate_at and imported_at are searchable and sortable but NOT stackable: ?stack=rate_at is 400 common.invalid_input with "Invalid stack field".

search.base_currency?string
search.target_currency?string
search.date?string
search.rate?number
search.type?string

BUY, SELL or MID. An unlisted value is not rejected — it returns 200 with no matching rows.

search.source?string
search.rate_at?string
search.imported_at?string

Response Body

application/json

application/json

application/json

application/json

application/json

application/json

application/json

curl -X GET "https://example.com/v1/fx/rates"
{
  "data": [
    {
      "base_currency": "EUR",
      "target_currency": "CHF",
      "type": "BUY",
      "date": "2026-08-27T00:00:00.000Z",
      "rate": "0.9412",
      "source": "SNB",
      "rate_at": "string",
      "imported_at": "2019-08-24T14:15:22Z"
    }
  ],
  "total": 0,
  "metadata": {},
  "has_more": true
}
{
  "status": 409,
  "message": "Spread rule overlaps an existing rule",
  "code": "fx_m.spread_overlap",
  "class": "business",
  "retryable": false,
  "details": [
    {
      "field": "percent_fee",
      "rule": "lte",
      "param": "1",
      "message": "string"
    }
  ]
}
{
  "status": 409,
  "message": "Spread rule overlaps an existing rule",
  "code": "fx_m.spread_overlap",
  "class": "business",
  "retryable": false,
  "details": [
    {
      "field": "percent_fee",
      "rule": "lte",
      "param": "1",
      "message": "string"
    }
  ]
}
{
  "status": 409,
  "message": "Spread rule overlaps an existing rule",
  "code": "fx_m.spread_overlap",
  "class": "business",
  "retryable": false,
  "details": [
    {
      "field": "percent_fee",
      "rule": "lte",
      "param": "1",
      "message": "string"
    }
  ]
}
{
  "status": 429,
  "message": "Rate limit for 203.0.113.7 to GET:/v1/fx/rates exceeded.",
  "code": "rate_limits_m.exceeded",
  "class": "temporary",
  "retryable": true
}
{
  "status": 409,
  "message": "Spread rule overlaps an existing rule",
  "code": "fx_m.spread_overlap",
  "class": "business",
  "retryable": false,
  "details": [
    {
      "field": "percent_fee",
      "rule": "lte",
      "param": "1",
      "message": "string"
    }
  ]
}

{
  "overall_status": "unhealthy",
  "message": "Service is shutting down",
  "timestamp": "2026-08-27T15:04:05Z"
}