CorebanqCorebanq Developer Docs
FXv2

Convert currency amount (v2)

Convert an amount between currencies. Only `amount` is strictly required — all other parameters fall back to operator-configured AppConfig defaults when omitted. Tariff is resolved via a four-step chain: fx_tariff_id query param → customer's assigned tariff (when customer_id provided) → convert.default_tariff_id AppConfig → the active tariff marked is_default = TRUE. THE APPCONFIG STEP CANNOT SUCCEED: getAppConfigFxTariff reads convert.default_tariff_id, unmarshals it into cfgID, and then queries Where("name = default AND active = true") — a literal with default unquoted, which PostgreSQL reads as the reserved word, so the statement is a syntax error rather than a lookup. The error is not ErrRecordNotFound, so the branch answers 500 common.database_error instead of falling through to the is_default row. The sample bundle ships the key set, so a convert that reaches this step on a default deployment fails; where the key is absent or inactive the step returns early and the is_default lookup is reached. FX date resolution is owned by the FX module business-date lookup policy, so omitted dates anchor to the current business date and optional lookback may resolve the most recent stored rate within the configured prior calendar-day window. Units: this surface carries the STORED fee range, so fee_range money fields are INTEGER MINOR units — unlike /v1/fx/rates/convert, which reports them in major units. amount_unit defaults to minor.

GET
/v2/fx/rates/convert

Authorization

bearerAuth
AuthorizationBearer <token>

In: header

Query Parameters

amount*number

Amount to convert in minor units (default). Use amount_unit=major for decimal units.

amount_unit?string

Unit of the amount parameter: 'minor' (default) for atomic units (e.g. 10000 = 100 EUR), 'major' for decimal units (e.g. 100.50)

base?string

Base currency code. Falls back to convert.default_base AppConfig entry (object format {"code":"EUR","precision":2}) when omitted.

target?string

Target currency code. Falls back to convert.default_target AppConfig entry when omitted.

customer_id?string

Customer UUID. When provided, the customer's assigned FX tariff is used as a fallback if fx_tariff_id is not specified. Customer-specific pricing takes precedence over the system default tariff.

fx_tariff_id?string

FX tariff UUID (highest priority). Falls back through: customer's tariff (when customer_id provided) → convert.default_tariff_id AppConfig → the active tariff marked is_default = TRUE.

type?string

Transaction type for fee-range matching (BUY/SELL). Falls back to convert.default_type AppConfig when omitted.

rate_type?string

Rate type for exchange-rate quote lookup (BUY/SELL/MID). Defaults to MID when omitted.

source?string

Rate source/provider (e.g., SNB, EB, CRP, OER). Falls back to convert.default_source AppConfig when omitted.

date?string

Lookup business date in YYYY-MM-DD format. When omitted, FX anchors lookup to the current business date in the configured timezone. If lookback is enabled, the returned currency_pair.date may be earlier than the anchor date.

indicative?string

Ask for an indicative rate. Compared against the LITERAL STRING "true": the handler evaluates r.URL.Query().Get("indicative") == "true", so "1", "TRUE", "yes" and an empty value all read as false, silently. It is not a boolean and no parse error is possible.

fee_ccy?string

When set, return all fees (total_fee, fee_range fixed_fee/min_fee/max_fee) in this currency instead of base currency

Response Body

application/json

application/json

application/json

application/json

application/json

application/json

application/json

application/json

curl -X GET "https://example.com/v2/fx/rates/convert?amount=0"
{
  "amount": {
    "amount": "10923",
    "currency": "USD",
    "precision": 2
  },
  "total_fee": {
    "amount": "10923",
    "currency": "USD",
    "precision": 2
  },
  "mid_rate": 1.0923,
  "fee_range": {
    "min_range": 0,
    "max_range": 0,
    "fixed_fee": 0,
    "percent_fee": 0.01,
    "min_fee": 0,
    "max_fee": 0,
    "method": "fixed",
    "base_currency": "67615cab-16f6-4751-af40-c6c0dbcaffe0",
    "target_currency": "3b3e8109-66d3-4a6d-b9f1-d0865976f9f8",
    "fx_tariff_id": "f12d958c-c80c-4752-862c-9e0c09506488",
    "date_start": "2019-08-24T14:15:22Z",
    "date_end": "2019-08-24T14:15:22Z",
    "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
    "created_at": "2019-08-24T14:15:22Z",
    "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4",
    "modified_at": "2019-08-24T14:15:22Z",
    "modified_by": "e8d4374d-93a1-4e98-a6c6-fdcf00c5059f",
    "active": true,
    "metadata": {}
  },
  "tariff": {
    "name": "string",
    "description": "string",
    "fallback_tariff_id": "e4b6bec8-3c7c-45ce-b8c3-85abac4ed74b",
    "is_default": true,
    "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
    "created_at": "2019-08-24T14:15:22Z",
    "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4",
    "modified_at": "2019-08-24T14:15:22Z",
    "modified_by": "e8d4374d-93a1-4e98-a6c6-fdcf00c5059f",
    "active": true,
    "metadata": {}
  },
  "currency_pair": {
    "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
    "base_currency": "string",
    "target_currency": "string",
    "base_ccy_precision": 0,
    "target_ccy_precision": 0,
    "rate": 0.1,
    "type": "BUY",
    "date": "string",
    "source": "string",
    "rate_at": "string",
    "imported_at": "2019-08-24T14:15:22Z"
  },
  "spread_rule_id": "82788b45-8d95-4a1a-86a2-bb04e0f38547",
  "applied_rule_ids": [
    "82788b45-8d95-4a1a-86a2-bb04e0f38547",
    "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  ],
  "fallback_tariff_used": false,
  "fallback_tariff_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
{
  "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": 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"
}

POSTCreate a new FX spread rule

Create a new FX spread rule with configurable bid/ask spreads

GETList FX spread rules

Lists spread rules in the shared list envelope. NO RECORD-LEVEL FILTER APPLIES. GetAllRules takes a userID parameter and never puts it in GetAllTotalInput, and sets no RbacRecordType, so every rule is returned to any authenticated caller. GetAllTotalInput.Validate does not catch this because it only rejects a missing record type when the user id is non-nil — here it is the zero value. Contrast /v2/fx/tariffs and /v2/fx/tariffs/fees, which are scoped. 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.