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.
| Where | Unit | Example (EUR) |
|---|---|---|
Fee range range_start, range_end, fixed_fee, min_fee, max_fee | Minor units, whole numbers | 25 is 0.25 EUR; 3000 is 30.00 EUR |
Velocity rule limit, when type is amount | Minor units of the rule's asset | 10000000 is 100 000.00 EUR |
Calculate-fee request amount.amount | Minor units, as a string | "10000" is 100.00 EUR |
Calculate-fee response fixed_fee, total_fee, min_fee, max_fee | Minor 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.01means 1%0.0015means 0.15%1means 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 feepercentage: Percentage of transaction amountgreater: Greater of fixed or percentagelesser: Lesser of fixed or percentagesum: 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 underSHAR),"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. ASHAtransfer priced on such a range is refused withtariffs.sha_share_not_declared, naming the tariff and the range — guessing who pays is worse than refusing, and a range that was never meant forSHAneeds no share. OURandBENnever read the field. A share outside0..100is rejected on write withtariffs.invalid_fee_range, and the database enforces the same bounds. So is a share carrying a third decimal: the column holds two, and storing33.33for a declared33.333would 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) oranyfor all transaction types - The transaction type must be an active product in the
products.productstable withtype = 'transfers' - Use
anyto 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_channelandmedia. - Every dimension is compared trimmed and without regard to case, so a rule scoped to
Directmatches a caller that sendsDIRECT. anymatches on either side: a rule scoped toanycovers every caller, and a caller that sendsany— 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 blankfrom_channelis charged to every caller that names no channel, while a velocity rule imported the same way stays dormant. Writeanywhen 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
anyescaped a channel-scoped limit while still being charged that channel's fee.
Velocity Types
transaction_count: Limit number of transactionsamount: Limit total transaction amount
Velocity Intervals
once: One-time limitdaily: Daily limitweekly: Weekly limitmonthly: Monthly limitquarterly: 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 descendingsearch.: Field filters with operators (.like,.eq, …) — searchable fields:id,name,description,fallback_tariff_id,default,active,created_at,created_by,modified_at,modified_bysearch: Free-text searchstack: Group results by a field —databecomes an object keyed by that field's values andkeyslists the groupsfilter,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
}| Field | Type | Description |
|---|---|---|
customer_id | uuid | Optional. Resolves velocity reference_id from customer metadata; use instead of sending reference_id manually |
reference_id | string | Optional when customer_id is set. Required when customer_id is omitted (explicit velocity bucket key) |
transfer_id | uuid | Optional. When commit is true, scopes velocity commit idempotency per transfer and rule |
amount.amount | string | Amount in minor units (e.g., "10000" = 100.00 EUR with precision 2) |
amount.currency | string | ISO currency code or asset code (e.g., "EUR", "USDC") |
asset | string | Asset filter for fee range matching. If omitted or "any", the currency from amount is used for fee denomination |
from_channel | string | Optional. Origin channel filter; defaults to any when omitted or empty |
to_channel | string | Optional. Destination channel filter; defaults to any when omitted or empty |
media | string | Required. Payment media filter (e.g. card, any) |
commit | boolean | When true, records velocity usage for matched rules |
reject_fallback | boolean | When true, a price that would come from a fallback tariff is returned as an error instead. See Fallback pricing |
fee_bearer | string | Optional. 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
}| Field | Type | Description |
|---|---|---|
fixed_fee | CcyAmtWithPrecision | Fixed component of the fee in minor units |
percent_fee | number | Decimal rate applied to the amount — 0.01 is 1%, not 1.0. See Amount Units |
total_fee | CcyAmtWithPrecision | Total calculated fee in minor units |
min_fee | CcyAmtWithPrecision | Minimum fee cap in minor units |
max_fee | CcyAmtWithPrecision | Maximum fee cap in minor units |
is_fallback | boolean | true when the price came from a tariff other than the one requested |
fallback_reason | string | Present only when is_fallback is true: velocity_exhausted or no_matching_fee_range |
fallback_from_tariff_id | uuid | Present only when is_fallback is true: the tariff the caller asked for |
fee_bearer | string | Present only when the request named a bearer: OUR, BEN or SHA — the bearer the split was computed for |
sender_share_percent | string | Present 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_fee | CcyAmtWithPrecision | Present only with a bearer: the part charged to the sender on top of the amount, rounded half up to the minor unit |
beneficiary_fee | CcyAmtWithPrecision | Present 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:
- Check the flag. Read
is_fallbackon every response. When it istrue, the price is a substitution andfallback_reasonexplains it.fallback_reasonandfallback_from_tariff_idare omitted entirely when the price came from the requested tariff, so their presence alone is a sufficient signal. - Refuse the substitution. Send
"reject_fallback": true. The calculation then returns422withtariffs.fallback_rejectedinstead of a substituted price, carryingreason,requested_tariff_idandfallback_tariff_idas 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 filterperiod_key: billing period, e.g.2026-06customer_id: restrict to one customerlimit,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
}| Field | Type | Description |
|---|---|---|
amount | number | Transaction amount in major units (e.g., 200.00) |
amount_minor | integer | Transaction amount in minor/atomic units (e.g., 20000) |
total_fee | number | Total calculated fee in major units (backwards-compatible) |
total_fee_minor | integer | Total calculated fee in minor/atomic units |
fixed_fee | number | Fixed fee component in major units |
fixed_fee_minor | integer | Fixed fee component in minor units |
percent_fee | number | Decimal rate (not a monetary amount, same in both) — 0.01 is 1% |
percent_fee_minor | number | Same as percent_fee (rate, not minor units) |
min_fee / min_fee_minor | number / integer | Minimum fee cap in major / minor units |
max_fee / max_fee_minor | number / integer | Maximum fee cap in major / minor units |
currency | string | Fee currency code |
precision | integer | Number of decimal places for the currency |
fee_bearer | string | Present only when the action named fee_bearer: the bearer the split was computed for (OUR, BEN, SHA) |
sender_share_percent | string | Present only with a bearer: the share of the total the sender bears, as a percentage |
sender_fee / sender_fee_minor | number / integer | Present 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_minor | number / integer | Present 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
| Code | Description |
|---|---|
| tariffs.tariff_not_found | Tariff not found |
| tariffs.tariff_already_exists | Tariff with this name already exists |
| tariffs.invalid_tariff_data | Invalid tariff data provided |
| tariffs.insufficient_rights | Insufficient permissions |
| tariffs.default_tariff_exists | Another default tariff already exists |
| tariffs.nested_rows_not_accepted | fee_ranges or velocity_rules were sent inside a tariff body; use their own endpoints (422) |
Fee Range Errors
| Code | Description |
|---|---|
| tariffs.fee_range_not_found | Fee range not found |
| tariffs.invalid_fee_range | Invalid fee range data |
| tariffs.overlapping_ranges | Fee ranges overlap |
| tariffs.invalid_date_range | Invalid date range |
| tariffs.invalid_calculation_method | Invalid fee calculation method |
| tariffs.invalid_percent_fee | percent_fee outside [0,1]; it is a decimal rate, 0.01 is 1% (400) |
| tariffs.fee_amount_overflow | The calculated fee does not fit the supported range (422) |
Velocity Rule Errors
| Code | Description |
|---|---|
| tariffs.velocity_rule_not_found | Velocity rule not found |
| tariffs.invalid_velocity_rule | Invalid velocity rule data |
| tariffs.velocity_limit_exceeded | Velocity limit exceeded, and the tariff has no fallback to substitute |
| tariffs.fallback_rejected | The requested tariff could not price the transaction and the request set reject_fallback (422) |
| tariffs.invalid_velocity_interval | Invalid velocity interval |
| tariffs.invalid_velocity_type | Invalid velocity type |
| tariffs.failed_to_create_velocity_rule | Failed to create velocity rule |
| tariffs.failed_to_update_velocity_rule | Failed to update velocity rule |
| tariffs.failed_to_delete_velocity_rule | Failed to delete velocity rule |
| tariffs.failed_to_retrieve_velocity_rule | Failed to retrieve velocity rule |
| tariffs.failed_to_read_velocity_usage | Failed to read velocity usage (500) |
Fee Calculation Errors
| Code | Description |
|---|---|
| tariffs.failed_to_calculate_fee | Fee calculation failed |
| tariffs.no_valid_tariff_entry | No valid tariff entry found |
| tariffs.invalid_transaction_data | Invalid transaction data |
| tariffs.unsupported_asset_type | Unsupported asset type |
| tariffs.unsupported_channel | Unsupported channel |
| tariffs.invalid_amount | Invalid amount |
| tariffs.sha_share_not_declared | A 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_amount | The 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) |