CorebanqCorebanq Developer Docs
Products

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. -1 skips data fetching and returns only the count (total/total_unfiltered); any other value <= 0 falls 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 search
  • search._text.like - Localized text search across resolved product name and description, including name_i18n / description_i18n values (example: search._text.like=Crypto w)
  • created_after - Filter by creation date
  • created_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 conversion
  • credits - Credit facilities and lending products
  • deposits - Deposit and savings products
  • cards - Card issuance and card transaction products
  • fee - 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 cents
  • max_amount - Maximum transfer amount in cents
  • daily_limit - Daily transfer limit in cents
  • monthly_limit - Monthly transfer limit in cents
  • processing_time - Processing time description
  • cutoff_time - Daily cutoff time for processing
  • supported_currencies - Array of supported currency codes
  • requires_approval - Whether manual approval is required
  • auto_approve - Whether auto-approval is enabled
  • risk_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 cents
  • percentage - Percentage fee (0.01 = 1%)
  • tiers - Array of fee tiers for tiered pricing
  • min_fee - Minimum fee in cents
  • max_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

  1. 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 optionally POST /v1/products/list) with partial PreFlightRequest filters. The response includes transfer_params when product, source, and destination are all pinned so the UI can show allowed/deny, limits, and fee hints before the user commits.

  2. 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, schema CreatePaymentRequest). Customer transactions v2 “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_idcustomer_id
product_ids[]product_code / product_id (server normalizes finalize product selection into pre-flight product_ids[] before matrix evaluation)
source_account_idResolved server-side from finalize source.counterparty_id + source.cp_account_id
destination_account_idResolved server-side from finalize destination.counterparty_id + destination.cp_account_id
destination_counterparty_idDerived internally from finalize destination.counterparty_id before exact destination normalization
channelchannel
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 active TRANSACTION_CHANNEL items 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_input with field: quote — the quote-lock inputs are inconsistent; the reason param says which.
  • transfers_m.quote_cannot_be_priced — only with quote.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_mismatch when amount.currency is not the source account's currency, plus transfers_m.invalid_amount_format and transfers_m.product_not_available.
  • 408 transfers_m.quote_cannot_be_priced when the FX converter times out.
  • 409 transfers_m.quote_idempotency_conflict / quote_already_used, only when the request carries quote.idempotency_key.
  • 422 and 503 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_exceeded from the quote's own miss guards, not from auth.RateLimitMiddleware. It fires whether or not the tenant has rate_limits_switcher on, so a client that skips 429 handling 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 request
  • 401 Unauthorized - Missing or invalid authentication
  • 403 Forbidden - Insufficient permissions
  • 404 Not Found - Product not found
  • 409 Conflict - Product code already exists
  • 500 Internal Server Error - Server error

Error responses include detailed error messages in multiple languages (EN, DE, FR, IT).

On this page