CorebanqCorebanq Developer Docs
Tariffs

Description

Purpose and use

Tariffs define what the bank charges, when it charges, and which customer or transaction context receives which price. They cover one-off fees, percentage fees, subscriptions, velocity limits, channel pricing, and product-specific fee behavior.

Who uses this. Product managers, finance operations, billing teams, treasury, and support teams use tariffs when defining customer pricing and explaining charges.

How it works. A tariff groups fee ranges, calculation methods, transaction types, channels, currencies, and velocity rules. During a transfer, subscription run, or fee calculation, the matching tariff determines the amount, currency, and posting behavior.

What users do. Users define or clone tariffs, set fee ranges and channels, configure velocity limits, review subscription charge history, and verify the tariff assigned to a customer or product.

Outcomes and side effects. Tariff changes affect future fees, customer disclosures, subscription billing, and transfer economics. Posted fees flow into ledger entries and audit history through the billing or transfer process that applies the tariff.

Related manuals: Products, Transfers, FX, Ops.

Overview

The Tariffs API provides functionality for managing pricing and fee calculations:

  • Tariff management (CRUD operations)
  • Fee range definitions
  • Velocity rules
  • Fee calculations
  • Multi-currency support
  • Channel-based pricing
  • Asset-specific fees

Core Concepts

Amount Units

Fee pricing is in minor units. Fee-range rows, the calculate-fee request and its response all speak the same unit, and the pricing path performs no conversion at all — a stored value is compared and charged exactly as written. Velocity amount limits use the same unit; see below the table.

WhereUnitExample (EUR)
Fee range range_start, range_end, fixed_fee, min_fee, max_feeMinor units, whole numbers25 is 0.25 EUR; 3000 is 30.00 EUR
Velocity rule limit, when type is amountMinor units of the rule's asset10000000 is 100 000.00 EUR
Calculate-fee request amount.amountMinor units, as a string"10000" is 100.00 EUR
Calculate-fee response fixed_fee, total_fee, min_fee, max_feeMinor units, with precision alongside{"amount": "25", "precision": 2} is 0.25 EUR

Fee-range values are integers. A fractional value is rejected — there is nothing below a minor unit to express — both by the API and by the seed importer.

:::note Velocity amount limits are minor units too An amount velocity limit is an integer in minor units of the rule's asset, the same unit as the fee-range bounds it sits beside — a 10 000.00 EUR monthly ceiling is 1000000. A transaction_count limit is still a count.

Migration 20260814150200 restates the limits and the accumulated tariffs.tariff_transactions.amount history behind them, taking each row's factor from the asset of the amount rule that wrote it. Usage written by a transaction_count rule is left as recorded — nothing sums that column for a count limit, so its unit cannot change a price. A rule that prices an amount without naming a currency (asset: any) cannot be converted from SQL, so the migration stops and names those rows rather than guessing a factor; COMMENT ON TABLE tariffs.velocity_rules IS 'minor units verified' accepts responsibility for exactly those rows and still converts every row whose currency is known. Rolling back reverses the same rows and refuses if a limit it cannot restate is present. The cached counters from the previous release are not reinterpreted either: the RAM key generation moves to v3 so they expire unread. :::

:::caution A fee range has no currency of its own asset may be any, so a stored value is read in the currency of whatever transaction is being priced. 3000 is 30.00 EUR, ¥3000, and 3000 satoshi. Do not ask one any range to price both fiat and crypto — give crypto its own rows with an explicit asset. :::

percent_fee is not a unit at all. It is a decimal rate, not a percentage:

  • 0.01 means 1%
  • 0.0015 means 0.15%
  • 1 means 100%, the highest value accepted

A percent_fee above 1 would price a fee larger than the transaction itself. It is rejected on create and update with tariffs.invalid_percent_fee, because in practice it means a percentage was sent where a rate was expected.

The database columns carry these rules as comments, so \d+ tariffs.fee_ranges in psql states the unit for every numeric column.

:::note Changed in this release These columns previously held major units and were multiplied by the transaction currency's precision at calculation time. Because a range carries no currency, that made one row mean different things per currency: min_fee: 30 imposed a 30 BTC floor on a BTC transfer, and at ETH's 18 decimals the conversion overflowed a 64-bit integer outright. Values are now stored pre-scaled, so nothing is multiplied and nothing can overflow. Seed files ship converted; a file still written in major units fails to import rather than loading at a hundredth of its intent. :::

Fee Calculation Methods

  • fixed: Fixed amount fee
  • percentage: Percentage of transaction amount
  • greater: Greater of fixed or percentage
  • lesser: Lesser of fixed or percentage
  • sum: Sum of fixed and percentage

Who bears the fee: the SHA share

A transfer names who bears its charges — OUR (the sender, on top of the amount), BEN (the beneficiary, deducted from the amount) or SHA (shared). The first two are fixed splits. For SHA the split is a property of the price, so it lives on the fee range, next to the fee it divides:

  • sender_share_percent — the share of the total fee the sender bears, as a percentage with up to two decimals: "50" is half, "100" the whole fee on the sender (the ISO 20022 convention for the sending bank's own charge under SHAR), "0" the whole fee deducted from the beneficiary. An exact decimal, so it travels as a string; a JSON number is accepted on write and it is always read back as a string. The sender's part is rounded half up to the minor unit and the beneficiary bears the remainder, so the two always add up to the fee.
  • Absent (null) means the range declares no split. A SHA transfer priced on such a range is refused with tariffs.sha_share_not_declared, naming the tariff and the range — guessing who pays is worse than refusing, and a range that was never meant for SHA needs no share.
  • OUR and BEN never read the field. A share outside 0..100 is rejected on write with tariffs.invalid_fee_range, and the database enforces the same bounds. So is a share carrying a third decimal: the column holds two, and storing 33.33 for a declared 33.333 would price a split the operator did not ask for while the response echoed the one they sent. A tariff file that declares such a share fails the import naming the range, rather than being rounded into the database.

Transaction Types

  • Transaction type filtering is available for both fee ranges and velocity rules
  • Valid values: Product codes of type transfers (e.g., iwt, owt, int, own, crw) or any for all transaction types
  • The transaction type must be an active product in the products.products table with type = 'transfers'
  • Use any to apply the rule to all transaction types

How a rule is matched

  • A fee range and a velocity rule are matched on the same dimensions: transaction type, asset, from_channel, to_channel and media.
  • Every dimension is compared trimmed and without regard to case, so a rule scoped to Direct matches a caller that sends DIRECT.
  • any matches on either side: a rule scoped to any covers every caller, and a caller that sends any — which is what naming no channel sends — is covered by a rule scoped to a concrete channel. Both sides work this way for fee ranges and velocity rules alike, so one tariff call cannot charge a channel's fee while skipping that channel's limit.
  • An empty dimension is where the two matchers differ, so read this one carefully. On a velocity rule an empty dimension is not a wildcard: it matches only a caller that sends nothing for it, and a caller that sends any — which is what naming no channel sends — does not match it. On a fee range an empty dimension still matches such a caller, because the wildcard test looks at the caller's side too. So a fee range imported with a blank from_channel is charged to every caller that names no channel, while a velocity rule imported the same way stays dormant. Write any when you mean "any value" and a concrete value when you mean to scope the row; leave nothing blank.
  • Velocity-rule dimensions were compared byte for byte until 2026-08, so a rule and a caller that named the same channel differently did not match and the limit was not applied — and a caller sending any escaped a channel-scoped limit while still being charged that channel's fee.

Velocity Types

  • transaction_count: Limit number of transactions
  • amount: Limit total transaction amount

Velocity Intervals

  • once: One-time limit
  • daily: Daily limit
  • weekly: Weekly limit
  • monthly: Monthly limit
  • quarterly: Quarterly limit (3 months)
  • annually: Annual limit

Endpoints

Tariff Management

Create Tariff

POST /v1/tariffs

Create a new tariff.

Request Body:

{
  "name": "standard",
  "description": "Standard pricing tier",
  "fallback_tariff_id": "uuid",
  "subscription_product_id": "uuid",
  "active": true,
  "default": false
}

Response:

{
  "id": "uuid",
  "name": "standard",
  "description": "Standard pricing tier",
  "fallback_tariff_id": "uuid",
  "subscription_product_id": "uuid",
  "active": true,
  "default": false,
  "created_at": "timestamp",
  "created_by": "uuid"
}

Get Tariff

GET /v1/tariffs/{id}

Get a specific tariff.

Response:

{
  "id": "uuid",
  "name": "standard",
  "description": "Standard pricing tier",
  "fallback_tariff_id": "uuid",
  "subscription_product_id": "uuid",
  "active": true,
  "default": true,
  "fee_ranges": [
    {
      "id": "uuid",
      "range_start": 0,
      "range_end": 100000,
      "fixed_fee": 250,
      "percent_fee": 0.01,
      "method": "sum",
      "min_fee": 200,
      "max_fee": 2000,
      "media": "card",
      "asset": "EUR",
      "from_channel": "pos",
      "to_channel": "bank_account",
      "transaction_type": "iwt",
      "valid_from": "2024-01-01T00:00:00Z",
      "valid_to": "2024-12-31T23:59:59Z"
    }
  ],
  "velocity_rules": [
    {
      "id": "uuid",
      "type": "transaction_count",
      "interval": "daily",
      "limit": 1000,
      "transaction_type": "any",
      "active": true
    }
  ]
}

Update Tariff

PUT /v1/tariffs/{id}

Update an existing tariff.

Request Body:

{
  "name": "standard",
  "description": "Updated standard pricing tier",
  "fallback_tariff_id": "uuid",
  "subscription_product_id": "uuid",
  "active": true,
  "default": false
}

:::warning Pricing rows are not accepted here This endpoint writes the tariff record only. A body carrying a populated fee_ranges or velocity_rules array is rejected with 422 and tariffs.nested_rows_not_accepted, naming the offending field and the endpoint that does write it. The same applies to POST /v1/tariffs.

Fee ranges and velocity rules are written through Fee Range Management and Velocity Rule Management.

An absent, null, or empty array discards nothing and is accepted, so reading a tariff and writing it back unchanged still works when it has no rows. To edit a tariff that has rows, strip them from the body before sending it. :::

Delete Tariff

DELETE /v1/tariffs/{id}

Delete a tariff and all associated fee ranges and velocity rules.

Get All Tariffs

GET /v1/tariffs

Retrieve all tariffs.

Query Parameters:

  • active: Filter by active status
  • name: Filter by name
  • search: Search in name/description

Response:

[
  {
    "id": "uuid",
    "name": "standard",
    "description": "Standard pricing tier",
    "fallback_tariff_id": "uuid",
    "active": true,
    "default": true,
    "fee_ranges": [
      {
        "id": "uuid",
        "range_start": 0,
        "range_end": 100000,
        "fixed_fee": 250,
        "percent_fee": 0.01,
        "method": "sum",
        "min_fee": 200,
        "max_fee": 2000,
        "transaction_type": "iwt"
      }
    ],
    "velocity_rules": [
      {
        "id": "uuid",
        "type": "transaction_count",
        "interval": "daily",
        "limit": 1000,
        "transaction_type": "any"
      }
    ]
  }
]

Get All Tariffs (v2, paginated)

GET /v2/tariffs

Paginated, search-oriented tariff listing following the shared getAll contract. Use this for operational grids and search screens; the v1 list above returns a plain array.

Query Parameters:

  • limit / offset: Paging (limit defaults to 10, capped at 100; offset defaults to 0)
  • sort: Comma-separated fields, prefix - for descending
  • search.: Field filters with operators (.like, .eq, …) — searchable fields: id, name, description, fallback_tariff_id, default, active, created_at, created_by, modified_at, modified_by
  • search: Free-text search
  • stack: Group results by a field — data becomes an object keyed by that field's values and keys lists the groups
  • filter, distinct, fill_gaps: Advanced getAll options

Response envelope:

{
  "data": [ { "id": "uuid", "name": "standard", "active": true } ],
  "total": 1,
  "total_unfiltered": 12,
  "has_more": false
}

data items are tariff rows (same shape as the v1 list, without relationship expansion unless loaded). With stack, data is an object of arrays and keys is present. metadata appears only when non-empty.

Clone Tariff

POST /v1/tariffs/{id}/clone

Duplicates a tariff with a new ID, copying all fee ranges and velocity rules. The clone is never marked as default.

Request Body (optional):

{
  "name": "Copy of Standard"
}

When name is omitted, the server uses Copy of {source name} and appends (n) if that name already exists.

Response: 201 Created with the full tariff (including fee_ranges and velocity_rules).

Export Tariff Seeds to Server DATA_DIR

POST /v1/tariffs/export-seed

Writes one .import.json file per tariff under DATA_DIR/tariffs on the API host.

If tariff_ids is omitted or empty, all tariffs are exported.

Request Body:

{
  "tariff_ids": ["uuid"]
}

Response:

{
  "dir": "/abs/path/to/DATA_DIR/tariffs",
  "written": [
    {
      "tariff_id": "uuid",
      "name": "standard",
      "filename": "standard-2f4c8b1d.import.json",
      "path": "/abs/path/to/DATA_DIR/tariffs/standard-2f4c8b1d.import.json"
    }
  ]
}

Fee Range Management

Create Fee Range

POST /v1/tariffs/ranges

Create a new fee range.

Request Body:

{
  "tariff_id": "uuid",
  "range_start": 0,
  "range_end": 100000,
  "fixed_fee": 250,
  "percent_fee": 0.01,
  "method": "sum",
  "min_fee": 200,
  "max_fee": 2000,
  "sender_share_percent": "50",
  "media": "card",
  "asset": "EUR",
  "from_channel": "pos",
  "to_channel": "bank_account",
  "transaction_type": "iwt",
  "valid_from": "2024-01-01T00:00:00Z",
  "valid_to": "2024-12-31T23:59:59Z",
  "active": true
}

sender_share_percent is optional: leave it out for a range that is not meant to price a SHA transfer. See Who bears the fee.

Get Fee Range

GET /v1/tariffs/ranges/{range_id}

Get a specific fee range.

Response:

{
  "id": "uuid",
  "tariff_id": "uuid",
  "range_start": 0,
  "range_end": 100000,
  "fixed_fee": 250,
  "percent_fee": 0.01,
  "method": "sum",
  "min_fee": 200,
  "max_fee": 2000,
  "media": "card",
  "asset": "EUR",
  "from_channel": "pos",
  "to_channel": "bank_account",
  "transaction_type": "iwt",
  "valid_from": "2024-01-01T00:00:00Z",
  "valid_to": "2024-12-31T23:59:59Z",
  "active": true
}

Update Fee Range

PUT /v1/tariffs/ranges/{range_id}

Update an existing fee range.

Request Body:

{
  "tariff_id": "uuid",
  "range_start": 0,
  "range_end": 2000,
  "fixed_fee": 300,
  "percent_fee": 0.015,
  "method": "sum",
  "min_fee": 300,
  "max_fee": 2500,
  "sender_share_percent": "50",
  "media": "card",
  "asset": "EUR",
  "from_channel": "pos",
  "to_channel": "bank_account",
  "transaction_type": "iwt",
  "valid_from": "2024-01-01T00:00:00Z",
  "valid_to": "2024-12-31T23:59:59Z",
  "active": true
}

Delete Fee Range

DELETE /v1/tariffs/ranges/{range_id}

Delete a fee range.

Velocity Rule Management

Create Velocity Rule

POST /v1/tariffs/velocity-rules

Create a new velocity rule.

Request Body:

{
  "tariff_id": "uuid",
  "reference_id": "customer_12345",
  "type": "transaction_count",
  "interval": "daily",
  "limit": 1000,
  "media": "card",
  "asset": "EUR",
  "from_channel": "pos",
  "to_channel": "bank_account",
  "transaction_type": "iwt",
  "valid_from": "2024-01-01T00:00:00Z",
  "valid_to": "2024-12-31T23:59:59Z",
  "priority": 10,
  "active": true
}

Response:

{
  "id": "uuid",
  "tariff_id": "uuid",
  "reference_id": "customer_12345",
  "type": "transaction_count",
  "interval": "daily",
  "limit": 1000,
  "media": "card",
  "asset": "EUR",
  "from_channel": "pos",
  "to_channel": "bank_account",
  "transaction_type": "iwt",
  "valid_from": "2024-01-01T00:00:00Z",
  "valid_to": "2024-12-31T23:59:59Z",
  "priority": 10,
  "active": true,
  "created_at": "timestamp",
  "created_by": "uuid",
  "modified_at": "timestamp",
  "modified_by": "uuid",
  "metadata": {}
}

Get Velocity Rule

GET /v1/tariffs/velocity-rules/{rule_id}

Get a specific velocity rule.

Response:

{
  "id": "uuid",
  "tariff_id": "uuid",
  "reference_id": "customer_12345",
  "type": "transaction_count",
  "interval": "daily",
  "limit": 1000,
  "media": "card",
  "asset": "EUR",
  "from_channel": "pos",
  "to_channel": "bank_account",
  "transaction_type": "iwt",
  "valid_from": "2024-01-01T00:00:00Z",
  "valid_to": "2024-12-31T23:59:59Z",
  "priority": 10,
  "active": true,
  "created_at": "timestamp",
  "created_by": "uuid",
  "modified_at": "timestamp",
  "modified_by": "uuid",
  "metadata": {}
}

Update Velocity Rule

PUT /v1/tariffs/velocity-rules/{rule_id}

Update an existing velocity rule.

Request Body:

{
  "reference_id": "customer_12345",
  "type": "amount",
  "interval": "monthly",
  "limit": 50000,
  "media": "any",
  "asset": "EUR",
  "from_channel": "any",
  "to_channel": "any",
  "transaction_type": "any",
  "valid_from": "2024-01-01T00:00:00Z",
  "valid_to": "2024-12-31T23:59:59Z",
  "priority": 5,
  "active": true
}

Note: The reference_id field is optional in update requests. If not provided, the existing value will be preserved.

Response:

{
  "id": "uuid",
  "tariff_id": "uuid",
  "reference_id": "customer_12345",
  "type": "amount",
  "interval": "monthly",
  "limit": 50000,
  "media": "any",
  "asset": "EUR",
  "from_channel": "any",
  "to_channel": "any",
  "transaction_type": "any",
  "valid_from": "2024-01-01T00:00:00Z",
  "valid_to": "2024-12-31T23:59:59Z",
  "priority": 5,
  "active": true,
  "created_at": "timestamp",
  "created_by": "uuid",
  "modified_at": "timestamp",
  "modified_by": "uuid",
  "metadata": {}
}

Delete Velocity Rule

DELETE /v1/tariffs/velocity-rules/{rule_id}

Delete a velocity rule.

Get Tariff Velocity Rules

GET /v1/tariffs/{id}/velocity-rules

Get all velocity rules for a specific tariff.

Response:

[
  {
    "id": "uuid",
    "tariff_id": "uuid",
    "reference_id": "customer_12345",
    "type": "transaction_count",
    "interval": "daily",
    "limit": 1000,
    "media": "card",
    "asset": "EUR",
    "from_channel": "pos",
    "to_channel": "bank_account",
    "transaction_type": "iwt",
    "valid_from": "2024-01-01T00:00:00Z",
    "valid_to": "2024-12-31T23:59:59Z",
    "priority": 10,
    "active": true,
    "created_at": "timestamp",
    "created_by": "uuid",
    "modified_at": "timestamp",
    "modified_by": "uuid",
    "metadata": {}
  },
  {
    "id": "uuid",
    "tariff_id": "uuid",
    "reference_id": "global",
    "type": "amount",
    "interval": "monthly",
    "limit": 50000,
    "media": "any",
    "asset": "EUR",
    "from_channel": "any",
    "to_channel": "any",
    "transaction_type": "any",
    "valid_from": "2024-01-01T00:00:00Z",
    "valid_to": "2024-12-31T23:59:59Z",
    "priority": 5,
    "active": true,
    "created_at": "timestamp",
    "created_by": "uuid",
    "modified_at": "timestamp",
    "modified_by": "uuid",
    "metadata": {}
  }
]

Fee Calculation

Calculate Fee

POST /v1/tariffs/calculate-fee

Calculate transaction fee.

Parameters:

  • value_date (optional): Future date for tariff calculation. If not provided, current date is used. Useful for calculating fees for future transactions based on scheduled tariff changes.

Velocity reference: Provide either customer_id (recommended) or reference_id. When customer_id is set, the API resolves reference_id from customer metadata as {customer_id}:{package_year} (customers.metadata.package_year, default year 1). With commit: true, pass transfer_id so velocity commits are idempotent per transfer and matched rule (transfer_id:rule_id).

Channel filters: from_channel and to_channel are optional. When omitted or sent as an empty string, the server defaults both to any (wildcard matching against fee ranges and velocity rules that use any).

Request Body:

The amount field uses the CcyAmt format — a monetary amount in minor/atomic units with its currency code. See Currencies - CcyAmt for details.

{
  "customer_id": "uuid",
  "transfer_id": "uuid",
  "amount": {
    "amount": "10000",
    "currency": "EUR"
  },
  "asset": "EUR",
  "from_channel": "pos",
  "to_channel": "bank_account",
  "media": "card",
  "transaction_type": "iwt",
  "value_date": "2024-12-25T10:00:00Z",
  "commit": true
}
FieldTypeDescription
customer_iduuidOptional. Resolves velocity reference_id from customer metadata; use instead of sending reference_id manually
reference_idstringOptional when customer_id is set. Required when customer_id is omitted (explicit velocity bucket key)
transfer_iduuidOptional. When commit is true, scopes velocity commit idempotency per transfer and rule
amount.amountstringAmount in minor units (e.g., "10000" = 100.00 EUR with precision 2)
amount.currencystringISO currency code or asset code (e.g., "EUR", "USDC")
assetstringAsset filter for fee range matching. If omitted or "any", the currency from amount is used for fee denomination
from_channelstringOptional. Origin channel filter; defaults to any when omitted or empty
to_channelstringOptional. Destination channel filter; defaults to any when omitted or empty
mediastringRequired. Payment media filter (e.g. card, any)
commitbooleanWhen true, records velocity usage for matched rules
reject_fallbackbooleanWhen true, a price that would come from a fallback tariff is returned as an error instead. See Fallback pricing
fee_bearerstringOptional. Who bears the fee — OUR, BEN or SHA (DEBT/CRED/SHAR accepted too, any case). When set, the response also reports sender_fee and beneficiary_fee; total_fee is the same either way. SHA needs the matched range's sender_share_percent and is refused with tariffs.sha_share_not_declared otherwise. See Who bears the fee

Response:

Monetary fee fields (fixed_fee, total_fee, min_fee, max_fee) are returned in CcyAmtWithPrecision format — minor units with currency and precision metadata. The percent_fee remains a numeric rate.

{
  "tariff_id": "uuid",
  "tariff_name": "standard",
  "fee_range_id": "uuid",
  "fixed_fee": {
    "amount": "250",
    "currency": "EUR",
    "precision": 2
  },
  "percent_fee": 0.01,
  "total_fee": {
    "amount": "350",
    "currency": "EUR",
    "precision": 2
  },
  "min_fee": {
    "amount": "200",
    "currency": "EUR",
    "precision": 2
  },
  "max_fee": {
    "amount": "2000",
    "currency": "EUR",
    "precision": 2
  },
  "method": "sum",
  "is_fallback": false
}
FieldTypeDescription
fixed_feeCcyAmtWithPrecisionFixed component of the fee in minor units
percent_feenumberDecimal rate applied to the amount — 0.01 is 1%, not 1.0. See Amount Units
total_feeCcyAmtWithPrecisionTotal calculated fee in minor units
min_feeCcyAmtWithPrecisionMinimum fee cap in minor units
max_feeCcyAmtWithPrecisionMaximum fee cap in minor units
is_fallbackbooleantrue when the price came from a tariff other than the one requested
fallback_reasonstringPresent only when is_fallback is true: velocity_exhausted or no_matching_fee_range
fallback_from_tariff_iduuidPresent only when is_fallback is true: the tariff the caller asked for
fee_bearerstringPresent only when the request named a bearer: OUR, BEN or SHA — the bearer the split was computed for
sender_share_percentstringPresent only with a bearer: the share of total_fee the sender bears, as a percentage ("100" under OUR, "0" under BEN, the range's sender_share_percent under SHA)
sender_feeCcyAmtWithPrecisionPresent only with a bearer: the part charged to the sender on top of the amount, rounded half up to the minor unit
beneficiary_feeCcyAmtWithPrecisionPresent only with a bearer: the part deducted from what the beneficiary receives; sender_fee + beneficiary_fee = total_fee

Fallback pricing

A tariff may name a fallback_tariff_id. When the requested tariff cannot price a transaction — its velocity limits for the period are exhausted, or none of its fee ranges match — the fee is taken from that fallback tariff instead. The price then comes from a different tariff than the one requested, and the response says so explicitly:

{
  "tariff_id": "uuid-of-standard",
  "tariff_name": "standard",
  "is_fallback": true,
  "fallback_reason": "velocity_exhausted",
  "fallback_from_tariff_id": "uuid-of-silver",
  "total_fee": { "amount": "15000", "currency": "EUR", "precision": 2 }
}

tariff_id always names where the price actually came from. When is_fallback is true, fallback_from_tariff_id names the tariff that was requested and fallback_reason says why it could not price the transaction.

A consumer must check is_fallback before treating the fee as the requested tariff's price. There are two supported ways to do that:

  1. Check the flag. Read is_fallback on every response. When it is true, the price is a substitution and fallback_reason explains it. fallback_reason and fallback_from_tariff_id are omitted entirely when the price came from the requested tariff, so their presence alone is a sufficient signal.
  2. Refuse the substitution. Send "reject_fallback": true. The calculation then returns 422 with tariffs.fallback_rejected instead of a substituted price, carrying reason, requested_tariff_id and fallback_tariff_id as error parameters. Use this where a fee must never be quoted from a tariff the caller did not ask for.

When the requested tariff has no fallback_tariff_id, there is nothing to substitute and the original failure is returned: tariffs.velocity_limit_exceeded or tariffs.no_valid_fee_range. reject_fallback does not change which error is returned in that case. In all modes, velocity is committed only after a fee has been calculated successfully; a no-range or fee-overflow failure consumes no usage.

The DSL tariff action mirrors this: its output map carries is_fallback, plus fallback_reason and fallback_from_tariff_id when the price was substituted.

:::tip Converting to major units To convert any CcyAmtWithPrecision value to major units: major = parseInt(amount) / 10^precision. For example, {"amount": "350", "currency": "EUR", "precision": 2} = 3.50 EUR. :::

Get customer velocity usage

GET /v1/tariffs/customers/{customer_id}/velocity-usage

Returns RAM-backed velocity usage for the customer's transfer and FX tariffs: per-rule used, limit, remaining, exceeded, ttl_seconds, and bucket_resets_at. Requires read permission on the customer record. transfer lists payment-tariff rules; fx lists FX spread rules (empty array when not applicable).

Migration 20260525180000_tariff_transactions_velocity_scope is a hard prerequisite for pricing any tariff with velocity rules. If its velocity-scope columns are missing, fee calculations and this endpoint fail closed instead of assuming zero usage. Fee calculations return tariffs.failed_to_calculate_fee (500); this endpoint returns tariffs.failed_to_read_velocity_usage (500).

{
  "customer_id": "uuid",
  "package_year": 2,
  "reference_id": "uuid:2",
  "transfer": [
    {
      "velocity_rule_id": "uuid",
      "tariff_id": "uuid",
      "tariff_kind": "transfer",
      "type": "amount",
      "interval": "annual",
      "limit": 100000,
      "used": 2500,
      "remaining": 97500,
      "exceeded": false,
      "package_year": 2,
      "reference_id": "uuid:2",
      "ttl_seconds": 86400,
      "bucket_resets_at": "2026-12-31T23:59:59Z",
      "asset": "EUR",
      "media": "any",
      "transaction_type": "any"
    }
  ],
  "fx": []
}

Subscription billing charges (fee queue)

GET /v1/tariffs/subscription-charges

Returns the organisation-wide queue of recurring subscription fee charges — the operator view for chasing unpaid fees. Each row carries the customer's customer_name for display alongside the id. The response also includes a summary block with the count and total amount per status and currency, so the queue header can show how many fees need attention and the outstanding exposure. Requires the org-wide tariff read permission.

Charges are recorded by the subscription_billing_daily job in the Ops module; the same module exposes the per-charge attempt history and the manual "Try again" action. The charge status follows the lifecycle documented under Recurring Subscription Fees.

Query Parameters:

  • status: comma-separated status filter (defaults to the attention set — everything except settled)
  • currency: ISO 4217 filter
  • period_key: billing period, e.g. 2026-06
  • customer_id: restrict to one customer
  • limit, offset: pagination
{
  "charges": [
    {
      "id": "uuid",
      "product_code": "MFEE",
      "customer_id": "uuid",
      "customer_name": "Demo Company",
      "account_id": "uuid",
      "business_date": "2026-06-01",
      "period_key": "2026-06",
      "amount": "9.9000",
      "currency": "EUR",
      "txn_id": "uuid",
      "status": "open_receivable",
      "attempts": 2,
      "last_error": "settle: no eligible account in currency"
    }
  ],
  "summary": [
    { "status": "open_receivable", "currency": "EUR", "count": 32, "total_amount": "316.8000" },
    { "status": "settled", "currency": "EUR", "count": 13, "total_amount": "128.7000" }
  ],
  "total_count": 45,
  "limit": 200,
  "offset": 0
}

List customer subscription charges

GET /v1/tariffs/customers/{customer_id}/subscription-charges

Returns recurring subscription billing charge rows from tariffs.subscription_charges for one customer, newest periods first, each including the customer's customer_name. Requires read permission on the customer record. Use txn_id with GET /v1/ledgers/entries?search.transaction_id= to inspect journal legs, or the Ops attempt-history endpoint for the full collection trail.

{
  "customer_id": "uuid",
  "charges": [
    {
      "id": "uuid",
      "product_code": "MFEE",
      "customer_id": "uuid",
      "customer_name": "Demo Company",
      "account_id": "uuid",
      "business_date": "2026-06-01",
      "period_key": "2026-06",
      "amount": "9.9000",
      "currency": "EUR",
      "txn_id": "uuid",
      "status": "settled",
      "attempts": 1,
      "last_error": null
    }
  ]
}

Change History (audit trail)

Every tariff, fee range, and velocity rule keeps a change history so operators and auditors can see who changed what and when. Three read endpoints return that trail:

  • GET /v1/tariffs/{id}/history — history for a tariff.
  • GET /v1/tariffs/ranges/{range_id}/history — history for a fee range.
  • GET /v1/tariffs/velocity-rules/{rule_id}/history — history for a velocity rule.

Each returns a plain list of history entries, newest first, and requires read permission on the parent record. An entry records the action taken, the acting user_id (and user_name when known), the created_at timestamp, and a changes list. Each change names the field and its old and new values, so a reviewer can trace every edit without leaving the tariff.

[
  {
    "id": "uuid",
    "action": "update",
    "user_id": "uuid",
    "user_name": "Jane Operator",
    "created_at": "2026-06-01T10:00:00Z",
    "changes": [
      { "field": "name", "old": "Standard", "new": "Standard 2026" }
    ]
  }
]

DSL Action Output

When fee calculation is invoked via the DSL transfer action, the output map provides both major units (backwards-compatible) and minor units for monetary amounts. The transaction amount / amount_minor is included for consistency with the exchange action, enabling uniform access to $state.*.amount_minor:

{
  "amount": 200.00,
  "amount_minor": 20000,
  "tariff_id": "uuid",
  "tariff_name": "standard",
  "fee_range_id": "uuid",
  "fixed_fee": 2.50,
  "percent_fee": 0.01,
  "total_fee": 3.50,
  "min_fee": 2.00,
  "max_fee": 20.00,
  "method": "sum",
  "is_fallback": false,
  "currency": "EUR",
  "precision": 2,
  "total_fee_minor": 350,
  "fixed_fee_minor": 250,
  "percent_fee_minor": 1.0,
  "min_fee_minor": 200,
  "max_fee_minor": 2000
}
FieldTypeDescription
amountnumberTransaction amount in major units (e.g., 200.00)
amount_minorintegerTransaction amount in minor/atomic units (e.g., 20000)
total_feenumberTotal calculated fee in major units (backwards-compatible)
total_fee_minorintegerTotal calculated fee in minor/atomic units
fixed_feenumberFixed fee component in major units
fixed_fee_minorintegerFixed fee component in minor units
percent_feenumberDecimal rate (not a monetary amount, same in both) — 0.01 is 1%
percent_fee_minornumberSame as percent_fee (rate, not minor units)
min_fee / min_fee_minornumber / integerMinimum fee cap in major / minor units
max_fee / max_fee_minornumber / integerMaximum fee cap in major / minor units
currencystringFee currency code
precisionintegerNumber of decimal places for the currency
fee_bearerstringPresent only when the action named fee_bearer: the bearer the split was computed for (OUR, BEN, SHA)
sender_share_percentstringPresent only with a bearer: the share of the total the sender bears, as a percentage
sender_fee / sender_fee_minornumber / integerPresent only with a bearer: the part charged to the sender on top of the amount, in major / minor units (re-denominated with fee_ccy like the rest)
beneficiary_fee / beneficiary_fee_minornumber / integerPresent only with a bearer: the part deducted from what the beneficiary receives; the two parts add up to total_fee

Asking for the split. A product does not name the bearer: the DSL grammar has no fee_bearer field in a tariff block, and writing one is a syntax error that stops the product loading at all. The bearer arrives on the event instead — the transfers executor publishes fee_bearer whenever the payment names one — and the action reads its fields from the event payload with the block's own fields merged over it, so a plain tariff block already comes back with the split. Either vocabulary is accepted (OUR/BEN/SHA or DEBT/CRED/SHAR).

A SHA on a range that declares no sender_share_percent fails the action with tariffs.sha_share_not_declared, so the product's on_error branch runs instead of a guessed 50/50 being booked. A beneficiary share larger than the amount it comes out of fails with tariffs.beneficiary_share_exceeds_amount, because the product would otherwise subtract it and try to book a negative entry. Without a bearer the six keys above are absent — not zero — so try(transfer_fee.sender_fee_minor, transfer_fee.total_fee_minor) reads the split when there is one and the whole fee otherwise.

tariff {
  type: "transfer"
  customer_id: $params.customer_id
  ori_amt: @event.txn_amt
  ori_ccy: @event.ori_ccy
  transaction_type: "OWT"
  commit: false
  output: transfer_fee
}
assign sender_fee_minor = try(@event.ori_fee_minor, try(transfer_fee.sender_fee_minor, 0))
assign ben_fee_minor = try(@event.ben_fee_minor, try(transfer_fee.beneficiary_fee_minor, 0))

When the transfer was priced by a locked quote, the event already carries the split the customer was shown — @event.ori_fee_minor and @event.ben_fee_minor (see the transfers module's event fields) — and the product should prefer those, as above, so the booking matches the quote to the minor unit.

:::tip DSL path references Use $state.tariff.amount_minor for the transaction amount and $state.tariff.total_fee_minor for fees in DSL expressions. Both transfer and exchange actions provide amount_minor, so you can access $state.*.amount_minor uniformly. :::

Dictionary Endpoints

Get Asset Types

GET /v1/tariffs/asset-types

Get available asset types.

Response:

[
  {
    "name": "EUR",
    "description": "Euro"
  },
  {
    "name": "USD",
    "description": "US Dollar"
  }
]

Get Channels

GET /v1/tariffs/channels

Get available channels.

Response:

[
  {
    "name": "pos",
    "description": "Point of Sale"
  },
  {
    "name": "bank_account",
    "description": "Bank Account"
  }
]

Get Media Types

GET /v1/tariffs/media-types

Get available media types.

Response:

[
  {
    "name": "card",
    "description": "Payment Card"
  },
  {
    "name": "bank_transfer",
    "description": "Bank Transfer"
  }
]

Get Fee Methods

GET /v1/tariffs/fee-methods

Get available fee calculation methods.

Response:

[
  "fixed",
  "percentage",
  "greater",
  "lesser",
  "sum"
]

Get Velocity Types

GET /v1/tariffs/velocity-types

Get available velocity types.

Response:

[
  "transaction_count",
  "amount"
]

Get Velocity Intervals

GET /v1/tariffs/velocity-intervals

Get available velocity intervals.

Response:

[
  "once",
  "daily",
  "weekly",
  "monthly",
  "quarterly",
  "annually"
]

Error Handling

All errors follow the standard API error body. A missing tariff:

{
  "status": 404,
  "message": "Error description",
  "code": "tariffs_m.tariff_not_found",
  "class": "business"
}

and a rejected field, which is where details and retryable actually appear:

{
  "status": 400,
  "message": "Invalid input",
  "code": "common.invalid_input",
  "class": "validation",
  "details": [
    {
      "field": "percent_fee",
      "rule": "lte",
      "param": "1",
      "message": "PercentFee must be less than or equal to 1"
    }
  ]
}

status is the HTTP status code echoed in the body; message is the localized description. The message keys below map to those descriptions.

code is the stable, locale-independent machine code (the message key) for automation to branch on. class is the coarse error family — business (a rule was violated; the same request will fail again), validation (the caller must fix a named field), or temporary (a transient condition that is safe to retry). retryable says whether repeating the request may succeed. details carries structured, field-level explanations; validation errors name the offending field.

Both retryable and details are omitted when they carry nothing — retryable when it is false, details when it is empty — so neither appears on the 404 above. Do not read their absence as anything but the zero value.

Error Codes

General Errors

CodeDescription
tariffs.tariff_not_foundTariff not found
tariffs.tariff_already_existsTariff with this name already exists
tariffs.invalid_tariff_dataInvalid tariff data provided
tariffs.insufficient_rightsInsufficient permissions
tariffs.default_tariff_existsAnother default tariff already exists
tariffs.nested_rows_not_acceptedfee_ranges or velocity_rules were sent inside a tariff body; use their own endpoints (422)

Fee Range Errors

CodeDescription
tariffs.fee_range_not_foundFee range not found
tariffs.invalid_fee_rangeInvalid fee range data
tariffs.overlapping_rangesFee ranges overlap
tariffs.invalid_date_rangeInvalid date range
tariffs.invalid_calculation_methodInvalid fee calculation method
tariffs.invalid_percent_feepercent_fee outside [0,1]; it is a decimal rate, 0.01 is 1% (400)
tariffs.fee_amount_overflowThe calculated fee does not fit the supported range (422)

Velocity Rule Errors

CodeDescription
tariffs.velocity_rule_not_foundVelocity rule not found
tariffs.invalid_velocity_ruleInvalid velocity rule data
tariffs.velocity_limit_exceededVelocity limit exceeded, and the tariff has no fallback to substitute
tariffs.fallback_rejectedThe requested tariff could not price the transaction and the request set reject_fallback (422)
tariffs.invalid_velocity_intervalInvalid velocity interval
tariffs.invalid_velocity_typeInvalid velocity type
tariffs.failed_to_create_velocity_ruleFailed to create velocity rule
tariffs.failed_to_update_velocity_ruleFailed to update velocity rule
tariffs.failed_to_delete_velocity_ruleFailed to delete velocity rule
tariffs.failed_to_retrieve_velocity_ruleFailed to retrieve velocity rule
tariffs.failed_to_read_velocity_usageFailed to read velocity usage (500)

Fee Calculation Errors

CodeDescription
tariffs.failed_to_calculate_feeFee calculation failed
tariffs.no_valid_tariff_entryNo valid tariff entry found
tariffs.invalid_transaction_dataInvalid transaction data
tariffs.unsupported_asset_typeUnsupported asset type
tariffs.unsupported_channelUnsupported channel
tariffs.invalid_amountInvalid amount
tariffs.sha_share_not_declaredA SHA fee was priced on a fee range that declares no sender_share_percent; the message names the tariff and the range (422)
tariffs.beneficiary_share_exceeds_amountThe beneficiary's share of the fee is larger than the amount it comes out of, so nothing would reach the beneficiary; the message names the bearer, the share and the amount (422)

On this page