Description
Purpose and use
Products define which banking services a customer can use and under which rules. They connect onboarding, account eligibility, transfer channels, pricing, DSL rules, quote handling, and pre-flight discovery before money movement starts.
Who uses this. Product managers, operations owners, treasury users, and configurator administrators use products to decide which accounts, currencies, channels, and transaction types are available.
How it works. A product carries commercial and operational parameters such as type, status, transaction channels, tariff references, DSL blocks, and pre-flight rules. Pre-flight checks combine the product matrix with source, destination, amount, currency, counterparty, and customer context.
What users do. Users browse the product catalog, review product rules, define product parameters, run pre-flight discovery, and choose a product before transfer finalization.
Outcomes and side effects. Product settings determine available transfer paths, fees, quote requirements, validations, and approval behavior. Changes affect future transactions and should be reviewed with tariffs and transfer rules before activation.
Related manuals: Transfers, Tariffs, FX, DSL API.
Overview
The products package provides functionality for managing banking products, their configurations, and settings. It supports the product types defined in the platform: transfers, foreign exchange, credits, deposits, cards, and fee (covering recurring subscription/maintenance charges such as MFEE), with flexible settings management.
API Methods
Get All Products
GET /v1/products
Retrieves a list of all products with advanced querying, filtering, sorting, and pagination capabilities.
Each data[] entry is a ProductCatalogRead: name and description are resolved for the request’s Accept-Language (same negotiation as pre-flight). Locale maps are not returned on this endpoint.
Query Parameters
active- Filter by active status (boolean)type- Filter by product type (string)code- Filter by product code (string)limit- Maximum number of results to return.-1skips data fetching and returns only the count (total/total_unfiltered); any other value<= 0falls back to the default limit.offset- Number of results to skip (default: 0)
Response
{
"data": [
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"code": "IWT",
"type": "transfers",
"name": "Internal Wire Transfer",
"description": "Transfer funds between internal accounts",
"settings": {
"min_amount": 100,
"max_amount": 100000000,
"daily_limit": 10000000,
"fee_structure": {
"type": "fixed",
"fixed_amount": 0
},
"processing_time": "instant",
"requires_approval": false
},
"active": true,
"created_at": "2024-03-20T10:00:00Z",
"created_by": "123e4567-e89b-12d3-a456-426614174001",
"modified_at": "2024-03-20T10:00:00Z",
"modified_by": "123e4567-e89b-12d3-a456-426614174001",
"metadata": {}
}
],
"total": 15,
"total_unfiltered": 20,
"has_more": false
}Advanced Query Parameters
sort- Sort field (e.g., "name", "created_at")order- Sort order ("asc" or "desc")search- Legacy top-level text searchsearch._text.like- Localized text search across resolved product name and description, includingname_i18n/description_i18nvalues (example:search._text.like=Crypto w)created_after- Filter by creation datecreated_before- Filter by creation date
Get Product by ID
GET /v1/products/{id}
Retrieves detailed information about a specific product by its UUID.
Response
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"code": "IWT",
"type": "transfers",
"name": "Internal Wire Transfer",
"description": "Transfer funds between internal accounts",
"settings": {
"min_amount": 100,
"max_amount": 100000000,
"daily_limit": 10000000,
"fee_structure": {
"type": "fixed",
"fixed_amount": 0
},
"processing_time": "instant",
"requires_approval": false,
"supported_currencies": ["EUR", "USD", "CHF"],
"compliance_rules": ["kyc_verification"]
},
"active": true,
"created_at": "2024-03-20T10:00:00Z",
"created_by": "123e4567-e89b-12d3-a456-426614174001",
"modified_at": "2024-03-20T10:00:00Z",
"modified_by": "123e4567-e89b-12d3-a456-426614174001",
"metadata": {}
}Get Product by Code
GET /v1/products/code/{code}
Retrieves a product by its code (e.g., "IWT", "OWT").
Get Products by Type
GET /v1/products/type/{type}
Retrieves all products of a specific type.
Supported Types
transfers- Wire transfers and payment routing (IWT, OWT, INT, OWN, OFR, …)fx- Foreign exchange and currency conversioncredits- Credit facilities and lending productsdeposits- Deposit and savings productscards- Card issuance and card transaction productsfee- Recurring subscription and maintenance charges (e.g. MFEE)
The value is used directly as a filter and is not validated against this set: an unrecognized type returns an empty list, not an error.
Create Product
POST /v1/products
Creates a new banking product with specified configuration.
Request Body
{
"code": "NEW",
"type": "transfers",
"name": "New Transfer Product",
"description": "Custom transfer product",
"settings": {
"min_amount": 500,
"max_amount": 50000000,
"daily_limit": 5000000,
"fee_structure": {
"type": "percentage",
"percentage": 0.001
},
"processing_time": "1-2 business days",
"requires_approval": true,
"supported_currencies": ["EUR", "USD"],
"compliance_rules": ["kyc_verification", "aml_check"]
},
"active": true
}Update Product
PUT /v1/products/{id}
Updates an existing product's configuration and settings.
Request Body
{
"name": "Updated Product Name",
"description": "Updated description",
"settings": {
"min_amount": 1000,
"max_amount": 100000000,
"fee_structure": {
"type": "tiered",
"tiers": [
{
"min_amount": 0,
"max_amount": 1000000,
"fixed_amount": 100
},
{
"min_amount": 1000000,
"percentage": 0.0005
}
]
}
},
"active": true
}Delete Product
DELETE /v1/products/{id}
Deletes a product from the system.
Product Settings Structure
Products support flexible configuration through the settings field, which can include:
Transfer Settings
min_amount- Minimum transfer amount in centsmax_amount- Maximum transfer amount in centsdaily_limit- Daily transfer limit in centsmonthly_limit- Monthly transfer limit in centsprocessing_time- Processing time descriptioncutoff_time- Daily cutoff time for processingsupported_currencies- Array of supported currency codesrequires_approval- Whether manual approval is requiredauto_approve- Whether auto-approval is enabledrisk_level- Risk level (low, medium, high)compliance_rules- Array of compliance rules to apply
Fee Structure
type- Fee type: "fixed", "percentage", or "tiered"fixed_amount- Fixed fee in centspercentage- Percentage fee (0.01 = 1%)tiers- Array of fee tiers for tiered pricingmin_fee- Minimum fee in centsmax_fee- Maximum fee in cents
Quote Settings
File-seeded products declare their FX provider once in the DSL metadata:
meta {
fx_source: "OER"
}Product seeding normalizes that value into settings.quote.fx_source, which is the authoritative
source for locked quote pricing and booking-rate inheritance. Supported canonical values are CC,
CMC, CRP, ECB, OER, and SNB; legacy display names are normalized during seeding.
A product containing an exchange tariff or any booking rate must declare meta.fx_source.
Missing or unsupported providers stop product seeding. Booking legs should state only their rate
type, for example rate = "MID-1ST". Existing SOURCE|TYPE legs remain valid when their source
matches the product declaration; a mismatch stops product seeding and identifies the affected line.
An exchange tariff must name its provider as a fixed value matching the product declaration;
provider expressions that change at runtime are refused. A booking-rate expression may be dynamic,
but its result must still be a supported text rate such as MID-1ST.
Because settings is refreshed from the product file at startup, operators should change the DSL
declaration rather than editing settings.quote.fx_source directly.
Version note — 17 September 2026. The bundled FLX0, FLX1, and MFEE definitions advance to versions 1.0.1, 1.0.3, and 1.0.1 respectively. This separates their revised FX booking policy from earlier audit and execution state. Operators who import these sample products should replace the previous definitions before restarting product seeding; no customer-facing workflow changes.
Localised Names and Descriptions (§17)
Product name and description are locale-resolved at read time. The source of truth is stored in two JSONB columns:
name_i18n— locale → display name mapping (e.g.{"en": "Outbound Wire Transfer", "de": "Ausgehende Überweisung"})description_i18n— locale → description mapping
Locale selection — send an Accept-Language header (BCP 47, e.g. de, fr-CH). The server picks the best match from supported locales and falls back to the tenant's configured default locale when no match is found.
Catalog read JSON (GET /v1/products list data[], GET /v1/products/{id}, GET /v1/products/code/{code}, GET /v1/products/type/{type}, and the POST / PUT response bodies) uses the ProductCatalogRead shape: name and description are always resolved strings from Accept-Language plus the tenant default — name_i18n / description_i18n are not serialized on those responses. Exception: GET /v1/products/{id} and GET /v1/products/code/{code} accept optional query include_i18n=true (1 / yes); when present and the caller has update permission on that product, the JSON body is a ProductCatalogEditorRead: same resolved fields plus name_i18n and description_i18n maps for admin tools (403 if the flag is set but update is denied). Pre-flight APIs use the same resolution rules on ProductSummary inside PreFlightResponse and ProductListResponse; those objects also expose only resolved strings, not locale maps.
Product matrix (pre-flight & list)
These customer-scoped endpoints evaluate active product matrix rules. They require a valid JWT and RBAC read on the target customer_id. The machine-readable contract lives in preflight.openapi.json alongside this file.
Client journey: discovery → transfer execution
-
Discovery — While the user narrows products, source account, destination (own account or counterparty), channel, currency, and amount, the client calls
POST /v1/products/pre-flight(and optionallyPOST /v1/products/list) with partialPreFlightRequestfilters. The response includestransfer_paramswhen product, source, and destination are all pinned so the UI can show allowed/deny, limits, and fee hints before the user commits. -
Execution — Pre-flight does not call the transfers API. When the user confirms, the client submits payment through
POST /v1/transfers/finalize(OpenAPI:OpenAPI schema, schemaCreatePaymentRequest). Customertransactionsv2 “complete” flows that create real money movement delegate to the same finalize path internally.
The finalize JSON uses different wire names than the pre-flight filter: finalize sends nested source and destination selectors (counterparty_id + cp_account_id) instead of flat internal filter fields such as source_account_id / destination_*, and uses amount.amount_minor instead of amount.amount on the wire — both represent minor-unit strings plus currency; precision is optional metadata. The server builds the same internal matrix input the pre-flight engine uses (transfers.PreFlightRequestFromCreatePayment), normalizes finalize product selection into pre-flight product_ids[] (resolving finalize product_code to a product UUID before matrix evaluation when needed), resolves source to the real debit source_account_id = accounts.accounts.id, resolves the exact destination to the universal pre-flight selector destination_account_id = counterparties.cp_accounts.id, runs products.ValidatePreFlightRequest, then products.PreFlightForFinalize before persisting the transfer. A matrix deny returns 422 with message code transfers_m.preflight_denied. Treat pre-flight responses as advisory for UX; the finalize request is authoritative subject to hydration, RBAC, idempotency, and quote rules.
Pre-flight filter (PreFlightRequest) | Finalize body (CreatePaymentRequest) |
|---|---|
customer_id | customer_id |
product_ids[] | product_code / product_id (server normalizes finalize product selection into pre-flight product_ids[] before matrix evaluation) |
source_account_id | Resolved server-side from finalize source.counterparty_id + source.cp_account_id |
destination_account_id | Resolved server-side from finalize destination.counterparty_id + destination.cp_account_id |
destination_counterparty_id | Derived internally from finalize destination.counterparty_id before exact destination normalization |
channel | channel |
amount (amount, currency, optional precision) | amount (amount_minor, currency, optional precision) — same minor-units semantics |
Pre-flight
POST /v1/products/pre-flight
Returns eligible products, counterparty-shaped sources, counterparty-shaped destinations, routes (viable active channels per source-account / exact destination cp_account_id), optional transfer_params when product, source, destination, channel, and amount are pinned, and foreign_counterparties_allowed. The target customer itself is represented as its own/private counterparty row, matching the counterparties domain model. Pre-flight returns only active product and route choices: inactive catalogue products, inactive matrix rules, inactive transaction channels, inactive source accounts, inactive destination counterparties, inactive bank accounts, and inactive crypto wallets are excluded. sources[] and destinations[] intentionally reuse counterparties response collection names: own_accounts[], bank_accounts[], and crypto_wallets[]. Canonical finalize selection is built from the parent row id plus the chosen nested instrument cp_account_id; clients send those as source.counterparty_id / source.cp_account_id and destination.counterparty_id / destination.cp_account_id. Nested bank_accounts[] / crypto_wallets[] rows also echo counterparty_id (the parent row's id; own_accounts[] carry no such field). products_allowed is carried by the rows the matrix filtered — foreign destination instruments and own accounts — and is omitted when the list is empty; instruments copied under the customer's own counterparty carry no product list at all. Every nested row also carries its own audit fields. cp_account_type (account | bank_account | crypto_wallet) and the legacy finalize_recipient hint are declared in the pre-flight schema but are not currently emitted — treat them as reserved, and build finalize selection from the parent id plus the nested cp_account_id. transfer_params.amount echoes the normalized evaluated amount in minor units with precision metadata. Each products[] entry is a ProductSummary including route (payment | convert | deposit) for client navigation.
customer_id must be in the body. resolvePreFlightCustomerID prefers a customer_id URL
path parameter and only then falls back to the body — but this route is registered as
/v1/products/pre-flight with no path segment, so chi.URLParam always returns empty and the URL
branch is unreachable. A body without a non-nil, non-zero customer_id is refused
400 common.invalid_input. The offending field is an AppError param, so it reaches the log rather
than the response: branch on code, and read details[] when the failure came from struct
validation.
A 422 here is usually a tenant configuration problem, not a bad request. None of its causes
is about the shape of the body:
products_m.preflight_transaction_channel_dictionary_empty— no activeTRANSACTION_CHANNELitems are configured. Pre-flight fails closed rather than falling back to wildcard-only routes, so every caller gets this until an operator seeds the dictionary. Retrying will not help.common.invalid_inputwithfield: quote— the quote-lock inputs are inconsistent; thereasonparam says which.transfers_m.quote_cannot_be_priced— only withquote.mode=lock, when the quote can be attempted but not priced: no FX result, an unconfigured converter, a fee in another currency than the amount, or fees that exceed it.
The neighbouring failure is a 503 products_m.preflight_transaction_channel_items_load_failed:
there the dictionary could not be read at all, where the 422 means it was read and is empty.
That one is temporary and worth retrying.
quote.mode=lock widens the error surface. populatePreFlightQuoteState hands the quote
service's AppError straight back out of pre-flight, so a locking call inherits everything
quotecore.CreateQuote can answer, and pre-flight's documented codes are the union:
400 transfers_m.quote_currency_mismatchwhenamount.currencyis not the source account's currency, plustransfers_m.invalid_amount_formatandtransfers_m.product_not_available.408 transfers_m.quote_cannot_be_pricedwhen the FX converter times out.409 transfers_m.quote_idempotency_conflict/quote_already_used, only when the request carriesquote.idempotency_key.422and503 transfers_m.quote_cannot_be_priced, split by whether the quote could be attempted and not priced or a dependency it needs is missing.429 transfers_m.quote_rate_limit_exceededfrom the quote's own miss guards, not fromauth.RateLimitMiddleware. It fires whether or not the tenant hasrate_limits_switcheron, so a client that skips429handling because the rate limiter is off will still meet it here.
The reason on those quote errors is an AppError param: it is logged and, where a translation
carries the placeholder, interpolated into the message. It is not a field of the response body —
StdResponse carries status, message, code, class, retryable and details only.
Payees held at this institution
A saved payee is a beneficiary the customer entered themselves, with a name and an account number. Nothing on that record says whether the account is at this bank or at another one, and the customer is not asked. Pre-flight now settles it: on every call it checks the customer's saved payee account numbers against the accounts this institution holds.
Who this affects. Customers paying another customer of this bank — most visibly a person or group paying between their own companies — and the support and operations staff who see the resulting transfer.
A payee whose account number turns out to be held here is treated as an internal destination. The pair is a book transfer between two customers of this bank: it is offered under the internal transfer product, priced on the internal tariff, and routed over the direct book channel rather than an interbank rail. The beneficiary is a separate legal entity, so the payee stays a counterparty on the response and is not merged into the customer's own accounts.
The response does not say who holds the account. That is deliberate, and it is where this differs from what a client might expect. Pre-flight resolves the holder internally, because it needs it to decide the product, the tariff and the channel — but confirming to one customer that a given person banks here is exactly what banking secrecy protects, and another customer of the same institution is a third party. So the identity stays inside: nothing on a payee or on a payee's accounts identifies the holder, and the payee-level customer identifier is now always empty there. The only customer named anywhere in the response is the paying customer themselves — on their own accounts, whether those appear as the source or as a destination — which tells the payer nothing they did not already know. It previously could carry an internal bookkeeping link which in some records is the paying customer, so a client reading it as the beneficiary would have credited the payment to the payer.
The payee-verification schemes now standard in Europe work the same way — Confirmation of Payee in the United Kingdom, Verification of Payee under the European rules. Neither answers "who holds this account". Both require the payer to state the name themselves and answer only whether it matches, and both return a name at all only when the payer was already close. Neither returns an internal customer identifier.
A payment that needs the beneficiary gets it when it is made, not when it is quoted: the client sends back the account it picked, and the institution resolves the holder on its own side. No client flow needs the identity in the quotation.
Accounts the paying customer already owns are deliberately excluded. A customer who saved one of their own account numbers as a payee is not paying another customer, and that pair belongs to the own-account products at their own pricing.
A payee at any other bank is unchanged: an outbound wire, on the wire tariff, over the interbank channel it was already routed on.
When the pair is refused. A client that asks specifically for the outbound wire product against such a payee is refused, and told that the destination is an account at this institution: a wire out is the wrong instrument for money that never leaves the bank, so it is refused rather than quietly sold. The same refusal covers any on-us pair the deployment's matrix does not allow — a currency pair its internal transfer product cannot book, for instance. Refusing is deliberate, and it happens twice: at quotation, and again on submission, because finalize runs the same matrix. The alternative is a transfer that is accepted and then stops with no posting to make.
What changed for you (31 August 2026). Before this, every saved payee was treated as being at another bank, so a payment between two customers of this institution was quoted and booked as an outbound wire, with the wire fee and interbank routing. Effective immediately, those pairs are quoted as internal transfers. Two things need checking before this is deployed. First, the matrix must carry a rule granting own → foreign with an internal destination scope, and the internal transfer product must be active; without one, a payment to a payee at this bank is refused rather than repriced, and the payee stops appearing as a destination at all. Second, a payment already quoted, saved as a draft, or scheduled under the outbound wire product is refused on submission rather than reclassified: POST /v1/transfers/finalize runs the same matrix and answers 422 transfers_m.preflight_denied with the same reason. The customer re-takes the quote and the payment goes through as an internal transfer; a locked quote cannot be re-pointed at another product. A customer who saved a payee at this bank before the change sees the new classification on their next attempt, without re-entering anything. Support should expect the fee on such payments to change from the wire tariff to the internal one.
Which pairs the matrix allows, and in which currencies, is configuration: see the product matrix rules the pre-flight engine reads. That is deliberate — which currency pairs an internal transfer can be booked in is a property of the product a given institution has configured, not of the platform, so it is set per deployment rather than fixed in the software. An institution whose rules still allow the internal transfer product in any currency should narrow them to the pairs its own product books, and should decide at the same time what it wants for a pair outside them: refused at quotation, or still sent as an outbound wire.
Product list
POST /v1/products/list
Returns products allowed for the customer scope. Embedded ProductSummary rows follow the same Accept-Language resolution rules as pre-flight and include route (payment | convert) so the client can pick the correct SPA screen.
customer_id comes from the body only here — there is no URL path fallback. ProductListRequest
tags it validate:"required", so a missing or zero uuid fails inside DecodeJSONInput and the
handler re-stamps it 400 common.failed_to_serialize carrying
details[0] = {field: customer_id, rule: required}; the handler's own common.invalid_input branch
for the zero uuid never runs. This route does not read the TRANSACTION_CHANNEL dictionary, so
neither the pre-flight 422 nor its 503 sibling can occur on it: besides that 400, its failures
are 401, 403 common.forbidden, 404 customers_m.not_found, 500 common.database_error, the
429 the rate limiter answers when rate_limits_switcher is on, and the two middleware 503s
that reach every route.
Matrix rules
GET|POST|PUT|PATCH|DELETE /v1/products/rules[/{id}] administer the rows the pre-flight engine
reads. They require RBAC on the global products record type; GET /v1/products/rules/{id}/history
additionally requires Administrator for the blame rows.
Write validation answers 400, not 422. validateRuleInput checks the four enum fields —
source_ownership, dest_ownership, source_account_scope, dest_account_scope — and answers
400 common.invalid_input, naming the offending field in the log; a body that fails to decode
answers 400 common.failed_to_serialize, and ruleInput carries no validate tags, so that one
never comes with details. PATCH accepts active alone, and a patch body without it is refused
400 common.invalid_input — rulePatch declares active and nothing else, so a body carrying
only priority or valid_to decodes to an empty patch and is refused. A malformed {id} is 400
on every route that takes one. DELETE answers 204 with no body, and a failed query is
500 common.database_error on all of them.
The listing is not paginated: GET /v1/products/rules returns the whole array, ordered
priority descending then created_at ascending for display only. Evaluation order is the reverse
on priority — ascending, first match wins. created_by and modified_by are nullable columns, so
both keys are always present and carry null for a row not written through this API.
Write (create / update)
Pass name_i18n and description_i18n objects in the request body to set locale-specific strings:
{
"code": "OWT",
"type": "transfers",
"name_i18n": {
"en": "Outbound Wire Transfer",
"de": "Ausgehende Überweisung",
"fr": "Virement sortant"
},
"description_i18n": {
"en": "Send funds to external bank accounts via wire transfer.",
"de": "Überweisen Sie Gelder an externe Bankkonten.",
"fr": "Envoyez des fonds vers des comptes bancaires externes."
}
}The legacy top-level name / description strings are still accepted on write and are stored on their existing columns. They act as the read-time fallback when name_i18n / description_i18n is absent or has no entry for the resolved locale (no automatic copy into the JSONB map is performed at write time).
Custom Fields
custom_fields- Additional custom configuration as key-value pairs
Error Handling
The API returns appropriate HTTP status codes and error messages:
400 Bad Request- Invalid input data or malformed request401 Unauthorized- Missing or invalid authentication403 Forbidden- Insufficient permissions404 Not Found- Product not found409 Conflict- Product code already exists500 Internal Server Error- Server error
Error responses include detailed error messages in multiple languages (EN, DE, FR, IT).