CorebanqCorebanq Developer Docs
Transfers

Description

Purpose and use

Transfers move customer value through controlled payment workflows. They combine product rules, source and destination selection, quotes, fees, KYT checks, signing, event execution, ledger posting, and status history.

Who uses this. Payment operations, treasury, compliance teams, support staff, customers, and product teams use transfers when creating, approving, investigating, or explaining money movement.

How it works. A transfer is created from a product, source, destination, amount, currency, and context. DSL events move it through validation, pre-flight checks, KYT, treasury, settlement, reconciliation, and completion while recording each status change.

What users do. Users create drafts, request quotes, finalize or cancel transfers, sign when required, execute allowed events, review status history, and trace ledger entries or compliance outcomes.

Outcomes and side effects. Transfers can reserve funds, post ledger entries, consume quotes, charge fees, send notifications, trigger KYT review, and update customer-visible balances. Failed or canceled transfers remain available for audit.

Related manuals: Products, Tariffs, Ledgers & Balance Management, KYT.

Overview

The Transfers API provides DSL-driven functionality for managing financial transfers:

  • Product-based DSL rule execution for transfer workflows
  • Support for SEPA, wire, internal, and crypto transfers
  • Inward transfer (IWT) processing with KYT validation
  • General Ledger (GL) entry automation
  • Multi-stage transfer lifecycle management
  • Event-driven architecture with audit trail

Core Concepts

DSL-Driven Workflows

Transfers are processed using Domain Specific Language (DSL) rules defined in Product settings. When a transfer is created:

  1. The system loads DSL rules from Product.Settings.dsl
  2. The DSL Engine executes the appropriate event rules (e.g., init, before_kyt, after_kyt)
  3. DSL actions affect the transfer (book GL entries, send notifications, update status)
  4. Events are recorded for audit trail

Transfer Types

TypeDescription
sepaSEPA transfer within the European payment area
wireInternational wire transfer
internalInternal transfer between customers
iwtInward transfer (from external to internal account)
owtOutward transfer (from internal to external account)
crypto_withdrawalCryptocurrency withdrawal
crypto_depositCryptocurrency deposit

Transfer Direction

The system automatically determines transfer direction based on account existence and ownership. The direction field indicates the flow of funds from the perspective of the system's customers.

Direction Types

DirectionDescriptionPerspective
outboundMoney leaving the system (customer sending)Originator is our customer
inboundMoney entering the system (customer receiving)Beneficiary is our customer
internalMoney moving within the systemBoth parties are our customers

Direction Determination Logic

The system determines direction by checking if ori_account_id and ben_account_id exist in the database:

1. Outgoing Transfer (outbound)

  • Condition: ori_account_id exists in the database
  • Meaning: Originator is our customer (money leaving the system)
  • Direction: outbound
  • Example: Customer sends money from their account to an external party
{
  "ori_account_id": "123e4567-e89b-12d3-a456-426614174002",  // ✅ Exists in DB
  "ben_iban": "DE89370400440532013000"  // External beneficiary
}

2. Incoming Transfer (inbound)

  • Condition: ori_account_id does NOT exist (or is null), but ben_account_id/ben_walletAddress/ben_iban exists
  • Meaning: Beneficiary is our customer (money entering the system)
  • Direction: inbound
  • Example: External party sends money to our customer's account
{
  // No ori_account_id (external originator)
  "ben_account_id": "123e4567-e89b-12d3-a456-426614174002"  // ✅ Exists in DB
}

3. Payment to another customer of the bank

  • Condition: ori_account_id is an account here and the beneficiary resolves to an account here that belongs to another customer — a saved payee whose IBAN is a house account, or an explicit ben_account_id
  • Meaning: money moves within the bank, from one customer to another
  • Direction: persisted as TWO rows:
    • the payer's row: direction = "outbound"
    • the beneficiary's row (the leg): direction = "inbound", written automatically — see Beneficiary legs
  • Example: a payment from one customer's account to another customer's account within the bank
{
  "ori_account_id": "123e4567-e89b-12d3-a456-426614174002",  // ✅ Exists in DB
  "ben_account_id": "123e4567-e89b-12d3-a456-426614174003"   // ✅ Exists in DB
}

Account Validation

The system validates account existence before determining direction:

  1. If ori_account_id is provided:

    • System checks if the account exists in the database
    • If account exists → outbound or internal (depending on ben_account_id)
    • If account does NOT exist → Error: ori_account_id not found (validation fails)
  2. If ben_account_id is provided:

    • System checks if the account exists in the database
    • If account exists → Used for customer resolution
    • If account does NOT exist → Treated as external beneficiary (for outgoing transfers)

Important Notes

⚠️ Current Limitation: The direction determination logic has a known issue:

  • If ori_account_id is provided but doesn't exist in the database, the system currently treats it as outbound before validation
  • Validation then fails with an error, but the direction was already incorrectly set
  • Recommended Fix: The system should validate account existence BEFORE determining direction

When a payee cannot be paid

A payee saved in the customer's own list names an account by its IBAN. When that IBAN turns out to be an account held at this institution, the payment is a book transfer between two customers rather than a payment to another bank, and the beneficiary is resolved from the payee the customer selected — with the same rules the quotation step applied to the same pair. A payee saved with spaces, hyphens or a non-breaking space in the IBAN resolves as readily as one saved plainly, and a customer's own account saved in their own payee list is not treated as a beneficiary: that payment leaves as it would to any other bank.

The money is then credited to the account that IBAN names — not to whichever account of that customer happens to match the payment's currency, which is what happened while only the customer was known. A beneficiary who holds two accounts in the same currency is paid on the one the payee actually points at, and two identical payments land in the same place.

Two situations end in a refusal rather than a payment — at submission, and equally when a payment is put into a schedule, because the payee is checked there too. In each of them the money has nowhere it can be put:

  • The account cannot take the credit — it is closed, or it has been deactivated. Nothing removes a payee when the account it names is closed, so a customer can hold such a payee indefinitely.
  • Two accounts here carry the same IBAN and the destination is genuinely in doubt. An IBAN identifies one account, so this is a fault in the bank's own data. It is in doubt when either of the two could be credited, and equally when neither can. Support has to repair the accounts before that payee can be paid.

One situation that looks like a third is not. An account of ours whose IBAN is operated by another bank — a funding account at a partner, a virtual IBAN issued elsewhere — cannot be credited by a book entry, because the money is not on our books. But the payment is perfectly deliverable: the bank that operates the IBAN receives it like any other. So it simply leaves as a wire, which is exactly what the customer was quoted, and nothing is refused.

A duplicate stops a payment only when it leaves the destination in doubt. When one of the two accounts can take the money and the other cannot, that one is credited and the payment goes through; the duplicate is a fault either way and is recorded for support, but it does not hold up a payment whose destination is not in question. When neither can take the money the customer is told about the duplicate rather than about one of the two accounts, because the reason belongs to one account and the payee names two.

A quotation does not check any of this: it establishes that the pair may be priced as an internal transfer, not that the account behind the payee can take the money today. So a payment quoted cleanly can still meet one of these refusals at submission, and support should expect that combination rather than treat it as a contradiction.

Each situation has its own message, written for that case and translated into every language the bank answers in, so the customer reads what applies to them rather than a general sentence with an internal label bolted on. None of them says anything about the account or who holds it — whose account an IBAN is, is not disclosed to the party paying it. Operations staff find the account in the service log, where it stays inside the bank.

What changed (1 September 2026). This applies to a payment addressed to a payee the customer picked from their own list — the route the modern payment endpoints use. A payment addressed by a bare IBAN, without a payee behind it, is unchanged. Before this, a payee saved with separators in its IBAN was treated as being at another bank, so a payment between two customers of this institution was routed and priced as one leaving it. A customer's own IBAN saved as a payee named that customer as the beneficiary of their own payment. A closed account was accepted as though it could be credited, and where two accounts shared an IBAN the payment went to whichever the system met first.

Support should expect a closed account, and a genuinely undecidable duplicate, to be refused at submission now with a reason, instead of being accepted and then having no posting to make. An account whose IBAN another bank operates is not refused at all — whatever its state on our books, it leaves as the wire the customer was quoted. No action is needed for existing payees; a customer meets the refusal only when they next try to pay one of the affected payees.

Beneficiary legs

A payment between two customers of the bank is one business event for two companies, so it is persisted as two rows: the payer's, and the beneficiary's — the leg. Each company reads, lists and opens its own row; neither is shown the other's.

When a leg is written. Whenever the row says a credit landed on another customer's account here: an outbound transfer, in a state where money actually moved, whose beneficiary is a customer other than the payer and whose recorded credited account belongs to that beneficiary. The beneficiary field can name an address-book payee rather than a customer, and the credited-account field a payee's account at another bank, so a payment that left the bank must not be read as a credit that stayed. A row that recorded no credited account earns no leg, whatever accounts the beneficiary holds: owning an account in the payment's currency says they could have been credited here, not that this payment did — a payment to a house IBAN whose product sent the money out over a rail has exactly that shape. Such rows are reported by the repair for an operator to settle. The decision is taken from the row, not from the product code, so it holds for whichever product a deployment uses for its internal transfers. A payment that credited nobody earns no row: a draft, a payment that failed or was cancelled, a schedule or a recurring rule that has not run, and in particular the cancelled draft that finalizing leaves behind, which still names the payee. A legacy payment recorded as type = "internal" by a legacy request keeps the leg it always had, but not for those states either, and not without an account of the beneficiary's for the credit to have landed on — except for an own-account move, where there is no second customer whose account could have taken it. The decision is read from the stored payment, never from the body of a repeated request: that body is sent again by the client and need not describe the payment its idempotency key names.

A repeated request finishes what a failed one started. The beneficiary's row is written after the money has been posted, so a failure at that last step answers the payer with an error for a payment that has already moved. Repeating the request with the same idempotency key writes the row the failed attempt owed the receiver, rather than answering success and leaving them without one; repeating it again changes nothing. No leg is written for a move between two accounts of one customer (one customer, one row), for an incoming wire (its single row already belongs to the beneficiary) or for an outgoing wire (it names no account here).

  1. The payer's row:

    • direction = "outbound"
    • ori_account_id = the payer's account
    • ben_account_id = the beneficiary's account here
    • parent_id = null
  2. The beneficiary's row (leg):

    • direction = "inbound"
    • ori_customer_id = the beneficiary (the row's own side)
    • ben_customer_id = the payer (the other side)
    • parent_id = the payer's row
    • written automatically, in the same request as the payer's row

What the leg carries. The amount that was credited: on a same-currency payment the payer's amount, on a cross-currency payment the beneficiary's amount and currency (the parent's ben_amt / ben_ccy, set from the consumed quote). The leg carries no counter-amount — the payer's debit is not the receiver's to read — and no fee, fee bearer or net amount, which are the payer's economics. For the same reason it carries none of the payer's pricing in its metadata: what they were quoted, the rate selected for them and the fee they were charged stay on their own row. The payment's own reference and the client's own metadata travel to the leg as before. Both rows share type and context, and are linked through parent_id.

The leg is the receiver's only row. search.customer_id on the v2 list returns, for the beneficiary, the leg and never the payer's outbound row: an outbound row is the payer's record, and a payment whose leg is missing is one the receiver has no row for yet, not one they sent. The caller has to be a user of the company named — a company they do not belong to is answered as a company that is no party (404, customer_not_a_party), whatever rows they may read; an operator who reads every transfer is exempt. GET /v2/transfers/{transfer_id} with customer_id answers the same way — see Get transfer by ID.

This promise is about a company's own view, and it is those two calls that make it. Every other listing shows payments, one row per payment, and never a leg: the v2 list asked without a company scope, the legacy GET /v1/transfers, the operator payments list, the counts behind all of them, and the payment-context and portfolio views. A leg is still opened directly by its own id, which is what a link from the receiver's list needs. The reasoning is the same one that gives the receiver a row at all: the leg is one company's record of a credit, so it means something inside that company's view and nothing outside it — read without that scope its parties are mirrored and it looks like a payment the beneficiary sent to itself.

A leg is a record, not an executable payment. Product events run on the payment, and so does a cancel; a request that aims either at a leg is refused as a validation failure. The event refusal names the payment to run it on. The cancel refusal does not, and deliberately: the reader is the receiving company, the payment is somebody else's, and naming it would hand them the id. The leg inherits the payment's product but none of its accounts or rail identity, so there is nothing for a product to act on there, and the receiving company holds update rights on its own leg.

status follows the parent. It is copied when the leg is written and then moved with the parent on every transition, in the same transaction, together with the completed_at / reversed_at timestamp that pairs with it — and the leg gets its own transfers.statuses row, so GET /v1/transfers/{leg_id}/status-history renders the movement's timeline. search.status on the receiver's list filters the leg's value. One status-changed event is dispatched per movement, for the parent.

Credit notice. When the receiver's row reaches completed — the money is final — every user of the credited company receives the incoming_transfer notification: the amount and currency credited, who paid, the payment reference, and a link to the receiver's own record. It is written in the same database transaction that made the payment final and delivered from there, so it cannot be lost between the two, and it fires for an incoming wire the same way. The payer's fee, rail and payee-book identifiers are not part of it. Each user receives it on the channels they enabled for the event; the in-app record is kept regardless. Payments repaired with the command below are settled history and produce no notice.

Operators — payments written before 6 September 2026. Until this change the leg was written only for requests saying type = "internal", which the modern finalize path never does, so on-us payments made through it left the beneficiary without a row — and, since the list no longer hands them the payer's row, without sight of those payments. Run corebanq transfers backfill-beneficiary-legs once after deploying (add a transfer id to repair a single payment). It looks only at payments that actually credited somebody, so drafts, cancelled and failed payments are left alone rather than reported. It writes the missing legs through the same code the live path uses, so it is safe to repeat, walks the history in pages, and resolves the credited account the way the ledger did — the account the row recorded, or the beneficiary's active account in the payment's currency. A parent whose beneficiary holds no such account is reported, not guessed; those are settled by hand.

metadata is not shared. The leg is created with the parent's client metadata only: the server-owned keys are stripped, _v2_finalize_payload among them, because that payload describes the payer's request — their selectors and their idempotency key — and is not the beneficiary's to read. Edits afterwards do not propagate in either direction.

Examples by Scenario

Scenario 1: Outgoing Transfer

{
  "ori_account_id": "123e4567-e89b-12d3-a456-426614174002",  // ✅ Our customer
  "ben_iban": "DE89370400440532013000"  // External party
}

Result: direction = "outbound" (money leaving the system)

Scenario 2: Incoming Transfer (Crypto Deposit)

{
  // No ori_account_id (external wallet)
  "ben_walletAddress": "0x555344432d455243323000000000000000000000"  // ✅ Our customer
}

Result: direction = "inbound" (money entering the system)

Scenario 3: Internal Transfer

{
  "ori_account_id": "123e4567-e89b-12d3-a456-426614174002",  // ✅ Our customer
  "ben_account_id": "123e4567-e89b-12d3-a456-426614174003"   // ✅ Our customer
}

Result:

  • Primary leg: direction = "outbound"
  • Secondary leg: direction = "inbound" (created automatically)

Scenario 4: Invalid Account (Error Case)

{
  "ori_account_id": "00000000-0000-0000-0000-000000000000"  // ❌ Does not exist
}

Result: Error ori_account_id not found (validation fails before direction is set)

Transfer Statuses

These are the complete machine names, defined in . No other status value exists.

StatusTerminalDescription
draftnoInitial state, transfer being prepared
pendingnoReady for processing, or in flight
schedulednoOne-shot future-dated payment awaiting its execution date
recurringnoRecurring-payment template; produces pending transfers on each cycle
completedyesSuccessfully completed
failedyesFailed to process
reversedyesTransfer reversed
cancelledyesCancelled by user

A transfer in a terminal status accepts no further transition (Transfer.IsTerminal). Intermediate processing — KYT screening, treasury handling, settlement — happens within pending and is observable through the transfer's events, not through a status of its own.

Status Transitions

draft ──→ pending ──→ completed
  │          │
  │          ├──→ failed
  │          │
  │          ├──→ reversed   (the product's reverse event, e.g. the payment rail refused the wire)
  │          │
  └──────────┴──→ cancelled

scheduled ──→ pending      (on the execution date)
recurring ──→ pending      (one per cycle)

failed is terminal: Transfer.IsTerminal returns true for it, so CanTransitionTo rejects every transition out of it. An earlier version of this diagram showed failed → reversed, which the runtime has never permitted.

reversed is reached through a product's reverse event. The outgoing-wire product routes there when screening rejects the payment or when the payment rail gateway refuses it; the reversal branch books the customer's money and fees back and the transfer ends reversed (terminal). A transfer that was already completed is never reversed in place — a later return of a settled payment is recorded as a parked inbound payment for an operator (see Payment rail gateway).

Amount Representation (Minor Units)

The Transfers API uses minor units (integers) for all amount fields, following the Stripe/PayPal pattern and ISO 4217 standards. This ensures exact precision and eliminates floating-point rounding errors.

All amount fields must be integers in the smallest currency unit:

  • txn_amt - Debit amount (from originator)
  • txn_ccy - Debit currency
  • ben_amt - Credit amount (to beneficiary, calculated based on FX and fee_mode)
  • ben_ccy - Credit currency
  • txn_feeAmt - Fee amount (in debit currency)
  • ori_fee / ben_fee - How the fee divides when a locked quote priced the transfer with a bearer: ori_fee is charged to the originator on top of the amount, ben_fee is taken out of what the beneficiary receives, and the two add up to txn_feeAmt. Absent otherwise, so a product falls back to its own tariff action output (see Fee context)
  • fee_mode - Fee policy: OUR (sender pays, on top of the amount), BEN (beneficiary pays, deducted from the amount), SHA (shared by the matched fee range's sender_share_percent)

Currency Precision Reference

CurrencyDecimal PlacesExample: 1.5Minor Units
USD, EUR, GBP2$1.50150
JPY, KRW0¥150150
BHD, KWD31.500 BHD1500
BTC80.00000001 BTC1
USDC61.5 USDC1500000
ETH181.5 ETH1500000000000000000

Examples

USD (2 decimal places):

  • $1.50 → 150 cents
  • $1000.00 → 100000 cents
  • $0.01 → 1 cent

USDC (6 decimal places):

  • 1.5 USDC → 1500000 micro-USDC
  • 100.0 USDC → 100000000 micro-USDC
  • 0.000001 USDC → 1 micro-USDC

BTC (8 decimal places):

  • 0.00000001 BTC → 1 satoshi
  • 1.0 BTC → 100000000 satoshis
  • 0.5 BTC → 50000000 satoshis

Common Mistakes

❌ Wrong: Using Floating-Point Numbers

{
  "txn_amt": 1.5  // Causes precision loss
}

❌ Wrong: Using Decimal Strings

{
  "txn_amt": "1.5"  // API expects integers
}

✅ Correct: Using Integers in Minor Units

{
  "txn_amt": 1500000,  // 1.5 USDC = 1,500,000 micro-USDC (6 decimals)
  "txn_ccy": "USDC"
}

Internal Architecture

  • API Boundary: Accepts/returns integers in minor units
  • Database Storage: Uses BIGINT columns to store amounts in minor units directly (no precision loss)
  • Go Model: Uses decimal.Decimal for in-memory representation (holds integer values)
  • Conversion: Automatic conversion at API boundary - no division/multiplication needed since DB stores minor units
  • DSL Events: Amounts are converted to major units (human-readable) for DSL rules and notifications

DSL Event Amount Fields

When DSL rules execute, amount fields are available in both formats:

FieldFormatExample (126 USDC)Use Case
@event.txn_amtMajor units (string)"126"Display, notifications
@event.txn_amt_minorMinor units (integer)126000000Calculations, ledger operations
@event.ben_amtMajor units (string)"50.5"Display, notifications
@event.ben_amt_minorMinor units (integer)50500000Calculations
@event.txn_netAmtMajor units (string)"125"Display
@event.txn_netAmt_minorMinor units (integer)125000000Calculations
@event.txn_feeAmtMajor units (string)"1"Display
@event.txn_feeAmt_minorMinor units (integer)1000000Calculations

DSL Event Ledger-Routing Fields

Products that post against purpose-state subledgers and rail-specific accounts (chart v 2026-09-02) receive these fields. The purpose subledgers are provisioned once per transfer, immediately before the init event's transaction opens — committed up front, because the event chain and the journal read outside that transaction — under the control ledger routed for the customer's type and the transfer currency; event assembly itself only looks the codes up and never creates, and a provisioned subledger survives an init that later fails (an empty account, reused on retry). All seven fields are server-owned — a value supplied in a caller payload is discarded. A field is absent when its side of the transfer is external, when no segregation route is configured, or (for rail) when the metadata names a rail no product routes on; a product booking an absent field fails closed.

Provisioning is best-effort and produces nothing at all when the transfer has no internal anchor on its own side — the beneficiary of an inbound transfer, the originator of an outbound one, each needing both an account and a customer. An inbound transfer whose beneficiary did not resolve to an internal customer therefore leaves the account with no purpose subledgers, and the next transfer that books one fails closed on the missing field until an account with a resolvable anchor (or a manual opening) creates them. Every such skip is logged under purpose routing:, naming the transfer and which of anchor, customer or currency was missing.

FieldContentAbsent when
@event.ori_account_ledger / @event.ben_account_ledgerThe side's current-purpose subledger codeside is external or the account has no current subledger
@event.ps_out_account_ledgerPending-settlement (outgoing) subledger code, anchored to the transfer's internal accountno internal anchor or no route for pending_settlement_out
@event.chi_account_ledgerCompliance-hold (incoming) subledger codeno internal anchor or no route for compliance_hold_in
@event.cho_account_ledgerCompliance-hold (outgoing) subledger codeno internal anchor or no route for compliance_hold_out
@event.fees_receivable_ledgerCustomer-anchored fee-accrual subledger codeno internal anchor or no route for fees_receivable
@event.railsic-rtgs or sic-ip, from the transfer metadata key railmetadata absent or names an unknown rail (products default via try(@event.rail, "sic-rtgs"))
@event.ori_balance_available_minor / @event.ben_balance_available_minorThe side's current-subledger available balance, minor units (integer)side is external or has no current subledger

The anchor is the internal account of the transfer: the beneficiary side for inbound, the originator side for outbound. regulatory_freeze has no event field yet — its anchoring is undecided, so RF product events fail closed by design.

Example DSL notification:

notify template = "deposit_received" {
  amount = "@event.txn_amt"        // "126" - human readable
  currency = "@event.txn_ccy"      // "USDC"
}
// Notification shows: "You received 126 USDC"

Example DSL calculation (balance check):

// Use minor units for ledger operations and comparisons
when evaluate $context.ori.account.balance gte @event.txn_amt_minor then sequence {
  // Sufficient balance - proceed with transfer
  gl-batch txn = $params.txn_id event = "init" {
    book ledger = $context.ori.account.ledger op = debit amount = @event.txn_amt_minor desc = "Debit"
  }
}

Endpoints

Create Transfer

POST /v1/transfers

Create a new transfer and trigger the init DSL event. The DSL rules are loaded from the Product associated with the provided product_id.

Flow:

  1. Customer initiates transfer with product_id
  2. System automatically resolves ori_customer_id and ben_customer_id from:
  • ori_account_id, ori_wallet_address, or ori_iban for originator
  • ben_account_id, ben_wallet_address, or ben_iban for beneficiary
  1. Transfer is saved with status draft
  2. DSL is loaded from Product.Settings.dsl
  3. DSL Engine executes init event rules with Transfer as @event
  4. DSL actions affect the Transfer (book GL, send notifications, update status)

Request Body:

{
  "tenant_id": "123e4567-e89b-12d3-a456-426614174000",
  "product_id": "123e4567-e89b-12d3-a456-426614174001",
  "ori_account_id": "123e4567-e89b-12d3-a456-426614174002",
  "type": "sepa",
  "amount": 100000,
  "currency": "EUR",
  "description": "Payment for invoice #12345",
  "ori_name": "John Doe",
  "ori_iban": "CH9300762011623852957",
  "ori_bic": "UBSWCHZH80A",
  "ben_name": "Acme Corp",
  "ben_iban": "DE89370400440532013000",
  "ben_bic": "COBADEFFXXX",
  "metadata": {
    "invoice_id": "INV-12345",
    "reference": "Payment Q4"
  }
}

Note: The amount field must be an integer in minor units (e.g., 100000 = €1,000.00 EUR, or 1500000 = 1.5 USDC).

Locked quote consumption: legacy POST /v1/transfers still consumes a quote from metadata.quote_id. Modern finalize flows use top-level quote_id instead.

Response (201 Created): the minor-units shape — txn_amt is an object, and there is no top-level txn_ccy. See "Every v1 transfer response is the minor-units shape" below.

{
  "id": "123e4567-e89b-12d3-a456-426614174100",
  "product_id": "123e4567-e89b-12d3-a456-426614174001",
  "product_code": "CRD",
  "ori_customer_id": "123e4567-e89b-12d3-a456-426614174050",
  "ori_account_id": "123e4567-e89b-12d3-a456-426614174002",
  "type": "sepa",
  "status": "pending",
  "active": true,
  "txn_amt": { "amount": "100000", "currency": "EUR", "precision": 2 },
  "txn_paymentPurpose": "Payment for invoice #12345",
  "ori_name": "John Doe",
  "ori_iban": "CH9300762011623852957",
  "ori_bic": "UBSWCHZH80A",
  "ben_name": "Acme Corp",
  "ben_iban": "DE89370400440532013000",
  "ben_bic": "COBADEFFXXX",
  "created_at": "2024-03-21T10:00:00Z",
  "updated_at": "2024-03-21T10:00:00Z"
}

Finalize transfer (after pre-flight discovery)

POST /v2/transfers/finalize

Creates a transfer after the user has already used POST /v1/products/pre-flight (and related list/catalog calls) to narrow products, source, destination, channel, currency, and amount. Pre-flight does not invoke this endpoint — the client does, once the user confirms.

The request body is CreatePaymentRequestV2 (not the raw PreFlightRequest JSON from pre-flight). Field names differ on the wire: finalize uses nested source and destination selectors, each with counterparty_id + cp_account_id, while pre-flight uses flat internal filter fields such as source_account_id and destination_account_id. The client builds these selectors from pre-flight results by combining the parent counterparty row id with the chosen nested instrument cp_account_id from own_accounts[], bank_accounts[], or crypto_wallets[]. The server maps the body to the same internal matrix filter as pre-flight (PreFlightRequestFromCreatePayment), resolves source to the real debit account id, normalizes destination to the exact universal counterparties.cp_accounts.id, runs products.ValidatePreFlightRequest, then products.PreFlightForFinalize; on deny it returns 422 with transfers_m.preflight_denied. Amount precision is derived by backend from amount.currency; clients should send amount_minor + currency, and precision is optional/ignored for canonicalization. The server then hydrates rows from storage and runs the same CreateTransfer + DSL init path as legacy POST /v1/transfers.

For cross-currency transfers, quote_id (from POST /v2/transfers/quotes) is required; otherwise the API returns 400 (transfers_m.fx_requires_quote). For same-currency transfers, quote_id is optional. Send quote_id at the top level of the body — not inside metadata.

Use metadata for client metadata in v2 requests. The old meta field is only for legacy POST /v1/transfers/finalize compatibility.

The response is TransferPaymentResponse, the same structured response shape used by the v2 draft flow.

Legacy note: POST /v1/transfers/finalize remains available for backward compatibility, but it is deprecated. It takes the same body and still uses v1 meta, and it answers with the v1 minor-units transfer shape rather than TransferPaymentResponse — the two routes share one handler and differ only in which response it writes.

See OpenAPI schema → CreatePaymentRequestV2 and PaymentPartySelector.

Create draft transfer (modern v2 flow)

POST /v2/transfers/create

Creates a transfer in draft status for the modern draft-first flow, when clients want a review/edit step before execution. The request body is DraftPaymentRequestV2 — the same shape as CreatePaymentRequestV2, but only source (and its owning customer_id) is required to save a draft; product, destination, amount, channel, fee bearer, and quote are all optional. The backend resolves the source (and the destination when supplied) and stores the finalize-shaped payload with the draft. Full contract validation (destination, amount, quote, and pre-flight prerequisites) is deferred to finalize, so a partial draft can be saved and edited freely.

Because a draft may have no amount yet, amount is omitted from TransferPaymentResponse until one is entered (distinguishing an unset amount from a real zero). product_id is omitted the same way while the draft has no product, so the response stays round-trippable: a full-replace PUT can resend the body a GET returned without tripping the supplied-but-invalid product rule below.

Optional means omitted, not blank: a field that is present is validated even when it carries a zero value. Sending "product_id": "00000000-0000-0000-0000-000000000000" is a supplied-but-invalid product, not an absent one, and returns 400 (transfers_m.product_id_or_code_required) unless a valid product_code accompanies it. Leave product_id out of the body to save a product-less draft.

Use the same header/body idempotency rules as finalize: X-Idempotency-Key may replace body idempotency_key, and both values must match when both are sent. A mismatch returns 400 Bad Request (common.invalid_input) with details[0].field = "idempotency_key" and details[0].rule = "header_body_mismatch"; omitting both returns the same code with details[0].rule = "required". A key longer than 255 characters returns the same field detail with rule = "max" and param = "255". The server stores drafts with an internal draft:-scoped idempotency key, so draft creation retries do not collide with the later finalized transfer.

On a first successful save the endpoint returns 201 Created. A retry with the same key for the same customer_id while the stored row is still draft and not claimed by finalize returns the existing draft with 200 OK and Idempotency-Replayed: true; the request body is not stored again. Replay grants are re-derived from the saved draft payload when it still resolves: the originator grant stays pinned to the saved customer_id, and a beneficiary grant is retried when the saved destination resolves to an internal beneficiary. If the saved destination can no longer be resolved after the draft exists, replay may fall back to persisted beneficiary columns only when they still match an internal account, IBAN, or wallet. Derived participant customer columns are not used by themselves to widen access on retry. Reusing the same key after the stored row has left draft returns 409 Conflict with transfers_m.draft_idempotency_key_reused, so a finalized/superseded draft cannot look like a successful new create. If the same key already belongs to another customer's draft-create row, create returns 409 Conflict with transfers_m.idempotency_key_conflict instead of returning that row.

The response is TransferPaymentResponse, projected in the same vocabulary as pre-flight/finalize rather than the legacy txn_* transfer shape. Amount-like fields use CcyAmtWithPrecision (amount, currency, precision) and selectors are echoed as source / destination with normalized cp_account_id values.

Response (201 Created):

{
  "id": "123e4567-e89b-12d3-a456-426614174100",
  "transfer_id": "123e4567-e89b-12d3-a456-426614174100",
  "status": "draft",
  "active": true,
  "product_code": "CRW",
  "customer_id": "123e4567-e89b-12d3-a456-426614174050",
  "direction": "outbound",
  "source": {
    "counterparty_id": "123e4567-e89b-12d3-a456-426614174201",
    "cp_account_id": "123e4567-e89b-12d3-a456-426614174202"
  },
  "destination": {
    "counterparty_id": "123e4567-e89b-12d3-a456-426614174301",
    "cp_account_id": "123e4567-e89b-12d3-a456-426614174302"
  },
  "sender": {
    "name": "Muster Handels GmbH",
    "iban": "CH9300762011623852957",
    "account_id": "123e4567-e89b-12d3-a456-426614174060",
    "customer_id": "123e4567-e89b-12d3-a456-426614174050"
  },
  "beneficiary": {
    "name": "Recipient Co",
    "wallet_address": "0x0000000000000000000000000000000000000001"
  },
  "channel": "CRYPTO",
  "fee_bearer": "DEBT",
  "quote_id": "9fd9c26f-5737-4c71-8f45-8c9b2f38743d",
  "reference": "Invoice 42",
  "amount": {
    "amount": "100000000",
    "currency": "USDC",
    "precision": 6
  },
  "fee_amount": {
    "amount": "500000",
    "currency": "USDC",
    "precision": 6
  },
  "amount_net": {
    "amount": "99500000",
    "currency": "USDC",
    "precision": 6
  },
  "recipient_amount": {
    "amount": "9128",
    "currency": "EUR",
    "precision": 2
  },
  "created_at": "2026-06-04T12:00:00Z",
  "updated_at": "2026-06-04T12:00:00Z"
}

Response (200 OK replay):

The body is the same TransferPaymentResponse shape as 201 Created, but the row is the already-stored live draft. The response includes:

Idempotency-Replayed: true

Errors (409 Conflict):

transfers_m.draft_idempotency_key_reused means the idempotency key belongs to a draft-create row that has already left draft. Use a new idempotency key for a new draft.

transfers_m.idempotency_key_conflict means the key belongs to another customer's draft-create row, so the endpoint refuses to return that draft.

transfers_m.draft_finalize_in_progress means the key belongs to a live draft that finalize has already claimed. Retry create only if finalize fails and releases the draft; otherwise finalize returns or links the executed transfer.

Edit draft transfer (modern v2 flow)

PUT /v2/transfers/{transfer_id}

Replaces an existing draft transfer created by POST /v2/transfers/create. The body is DraftPaymentRequestV2 with full-replace semantics: it is the complete draft state, so any field omitted from the request is cleared. Only source (and its owning customer_id) stays required; a draft may be edited as many times as needed while it remains in draft status. Contract validation is deferred to finalize, matching create.

Editing a transfer that is no longer a draft returns 400 with the current status. A draft saved without a destination may have one filled in later — that is the point of the relaxed create, so the beneficiary customer is written to ben_customer_id and the matching RBAC grants are issued as part of the edit — read access, because the draft belongs to the company making the payment. From/To may otherwise be edited within the same customer(s), but an edit that would move the draft to a different owning customer is rejected with 400 until participant-ownership reassignment (columns + RBAC grants) is supported: changing the originator returns reason: originator_customer_change_unsupported (customer_id), and changing or clearing an existing internal beneficiary returns reason: beneficiary_customer_change_unsupported (destination). The response is TransferPaymentResponse, projected like create.

An edit that races a finalize for the same draft returns 409 (transfers_m.draft_finalize_in_progress, reason: draft_finalize_in_progress). Finalize claims the draft under a row lock before reading its payload, so edit and finalize are serialized: whichever claims first wins. Without that, an edit could commit and return 200 after finalize had already read the pre-edit payload — the finalized transfer would reflect the old state and the edit would be silently discarded when the draft is superseded. If a finalize fails it releases its claim, so the draft becomes editable again and the edit can be retried.

The claim covers every mutation path for a draft, not just this endpoint: PUT/PATCH /v1/transfers/{transfer_id}, PATCH /v1/transfers/{transfer_id}/cancel, and DELETE /v1/transfers/{transfer_id} return the same 409 while a finalize is in flight. Cancelling or deleting a claimed draft would strand a finalize whose transfer has already been created and posted, leaving it permanently unlinked from the draft.

List transfers (modern v2 flow)

GET /v2/transfers

Lists transfers with the standard get_all envelope and TransferPaymentResponse items. Use this endpoint when clients need the same selector-shaped and precision-aware transfer model returned by POST /v2/transfers/create, POST /v2/transfers/finalize, and POST /v2/transfers/{transfer_id}/finalize.

The endpoint supports limit, offset, sort, stack, search_text, and search.{field} filters. When limit is omitted, the default page size is 10; use limit=-1 to return counts without materializing transfer rows. total counts transfers after RBAC, base filters, and search. total_unfiltered counts transfers after RBAC and base filters, before search.

Search operators:

  • default / .eq, .ne
  • .gt, .gte, .lt, .lte
  • .in, .nin
  • .like, .start_with, .end_with
  • search._text, search._text.like, search._text.ilike, search._text.contains, search._text.start_with, search._text.end_with

Search fields:

  • IDs: id, transfer_id, product_id, customer_id, quote_id
  • transfer state: status, active, direction
  • amounts/currencies: amount, amount_currency, fee_amount, fee_currency, amount_net, recipient_amount, recipient_currency
  • selectors: source.counterparty_id, source.cp_account_id, destination.counterparty_id, destination.cp_account_id
  • payment details: reference, created_at, updated_at
  • virtual product filter: product_code, product_code.eq, product_code.in

Selector UUID fields support default / .eq, .ne, .in, and .nin; .in / .nin accept up to 50 UUIDs. Pattern and range operators such as .like, .start_with, .gt, and .lte are rejected for selector fields.

customer_id (default and .eq) scopes the list to a customer on either side — the transfers it sent and the ones it received. It is the one v2 search field that is not a plain column comparison, so any other operator is rejected with query_m.invalid_operator rather than quietly narrowed to the originator. A payment between two customers of the bank is persisted as two rows (the payer's and the beneficiary's leg); the beneficiary is listed on its own leg, not on the payer's row, so the movement appears once per customer. Legacy GET /v1/transfers is unchanged: there customer_id means the originator alone.

Asked without customer_id, the list returns payments — one row per payment — and never a beneficiary's leg. An operator's list is a list of what the bank processed, and a leg is a company's own record of a credit rather than a second payment. total and total_unfiltered count the same rows the list returns.

Sort fields use the same v2 fields as search, except product_code and selector fields are not sortable because they are resolved as virtual/product-payload filters before storage sorting. Prefix with - for descending, for example sort=-created_at.

Stack fields group the v2 response by top-level TransferPaymentResponse fields: id, transfer_id, status, active, product_id, product_code, customer_id, channel, fee_bearer, quote_id, reference, created_at, updated_at.

The list contract intentionally does not expose legacy transfer detail fields such as ori_iban, ori_bic, ben_name, ben_iban, or ben_bic in v2 filters. Selector search follows the v2 response vocabulary (source.counterparty_id, source.cp_account_id, destination.counterparty_id, destination.cp_account_id); legacy/internal underscore aliases such as source_counterparty_id and destination_counterparty_id are not accepted.

Invalid search, sort, stack, and operator errors use the localized query_m.* message keys from the query module, for example query_m.invalid_search_field, query_m.invalid_sort_field, query_m.invalid_field, and query_m.invalid_operator.

GET /v2/transfers?limit=10&offset=0&search.status.in=draft,pending&search.product_code=CRD&search.source.counterparty_id={counterparty_id}&search.destination.cp_account_id={cp_account_id}&search_text=invoice&sort=-created_at

Response (200 OK):

{
  "data": [
    {
      "id": "123e4567-e89b-12d3-a456-426614174100",
      "transfer_id": "123e4567-e89b-12d3-a456-426614174100",
      "status": "draft",
      "active": true,
      "product_code": "CRD",
      "customer_id": "123e4567-e89b-12d3-a456-426614174050",
      "amount": {
        "amount": "100000",
        "currency": "EUR",
        "precision": 2
      },
      "created_at": "2026-06-04T12:00:00Z",
      "updated_at": "2026-06-04T12:00:00Z"
    }
  ],
  "total": 1,
  "total_unfiltered": 1,
  "has_more": false
}

Get transfer by ID (modern v2 flow)

GET /v2/transfers/{transfer_id}

Who may act on a payment. Each company reads its own row of a payment. The payer reads the row they created and is the only one who can act on it — cancel it, edit it while it is a draft, or run a product event on it. The beneficiary reads the inbound row written whenever a payment between two customers of the bank records a credit to an account they own (type = "internal" remains only the legacy way a move between one company's own accounts earns one), keeps full rights on that row, and holds no access to the payer's row at all; their activity entries open their own row, not the payer's.

A payment that recorded no credited account earns no such row and is reported by the repair. The receiving company is granted nothing on the payer's row all the same — the exclusion is decided from the row's direction and parties, not from whether a leg follows — so until an operator settles the row and runs corebanq transfers backfill-beneficiary-legs it holds no readable record of the payment and no feed entry. A row granted before this change keeps the read it was issued only while a leg is still possible: the repair migration withdraws it from parents whose leg exists when it runs and from leg-less parents that can never earn one (drafts, cancelled or failed payments, scheduled and recurring rows, templates), and leaves it on a leg-less row in a live status until the backfill has run. A credit arriving is not restricted this way on either side: the receiving company has to be able to run the product event that releases or returns the money, and on those rows the beneficiary field does not always name a company.

The rule is applied where access is granted, so it governs new payments from the change onwards. For older payments, migration 20260916091103 first ensures each beneficiary role that could read the payer's row can read the leg, repairs the activity entries, and then withdraws the payer-row grant. Being SQL it cannot reach the permission cache; the application flushes rbac:rec:* once on its first boot after the migration (a one-shot in-process migration step). That step waits, unrecorded, until it sees the migration's version committed cleanly in schema_migrations, so it never records a flush before the revoke has landed; a flush that fails against a configured Redis fails the boot and retries, so no cached pre-revoke decision outlives the deploy, while a process with no cache configured at all — a local build with no Redis — records a no-op and boots.

Reads one transfer by ID and returns TransferPaymentResponse. RBAC is the same as legacy GET /v1/transfers/{transfer_id}, but the response uses v2 source / destination, amount objects, quote_id, fee_amount, amount_net, and the legacy blended txn_* shape is gone. Both versions project the same public metadata — see Server-owned metadata keys.

direction (outbound | inbound) is server-owned and always present. An internal transfer has no third value — it is persisted as two rows, outbound on the payer's and inbound on the beneficiary's leg. It is what tells an incoming payment from an outgoing one: source / destination describe the payer's view, so they cannot.

customer_id (query, optional) names the company the caller acts for, and the caller has to be a user of that company: a company the caller does not belong to is answered as a company that is no party, whatever rows the caller may read. It disambiguates callers who belong to both parties of an on-us payment. The read answers with that company's own row: the parent for the payer (and for an incoming wire, whose single row is the beneficiary's), or the beneficiary's leg for an outbound parent. A beneficiary cannot use an old payer-row bookmark after its legacy grant is withdrawn; activity entries are migrated to the leg's own ID instead. A company that is no party to the transfer, the payer asking for the beneficiary's leg, a caller who names a company whose row they hold no access to, and a beneficiary whose leg does not exist yet all receive 404; its details name the rule under the customer_id field — customer_not_a_party or beneficiary_row_missing — so a client can tell "not yours" from "not yet". Naming a company that is not yours is answered the same way whether or not that company's row exists, so it is never a way to ask about someone else's record. The payer's row is never served as the receiver's receipt. Omitted, the read is unchanged.

On a beneficiary's leg both column prefixes are mirrored, names and ids together: ori_* is the side the row belongs to (the beneficiary) and ben_* the payer. Only this v2 read reports ABSOLUTE sides, so it swaps in its own projection; the legacy GET /v1/transfers list and the deprecated transactions surface pair each prefix with itself, which means they label a leg's owner as its sender. That is self-consistent — each object names and identifies the same party — and it is the behaviour those reads had before the leg carried any names at all.

sender and beneficiary name the two sides from the transfer's own record — name, iban, bic, wallet_address, account_id, customer_id, each omitted when empty, and the whole object omitted when a side carries nothing (a draft that has picked only a source has no beneficiary). customer_id is the party id that side resolved to — a customers row when the party is a customer of the bank, a recipients row for a beneficiary addressed as an external recipient, since the column carries either and has no foreign key. On a beneficiary's leg it is also not what the column prefix suggests: the leg mirrors the ids so its owner holds it through ori_customer_id while the names follow the parent, and the response un-crosses them. account_id is the account row that side resolved to — an internal account when the party is a customer of the bank, a recipient-bank-account row for an external payout — so resolve it against the surface the party belongs to rather than assuming the accounts API. They are display data, not selectors: source / destination remain the machine-readable pre-flight pair used to repeat a payment, and they exist only for transfers created through the v2 finalize flow. A transfer created through v1, an inbound payment, and any transfer written before the finalize payload was persisted have no selectors at all — sender / beneficiary are how a client names the other side for those.

Server-owned metadata keys

Three keys inside transfers.metadata belong to the server, not to the client: _v2_finalize_payload (the draft's authoritative finalize request), _superseded_transfer_id and _finalize_claim. They are stripped from client input, so a client can neither forge nor delete them, and they are stripped again on the way out — from the v1 and v2 transfer responses, from the customer transaction projection, and from the rules event, where they are absent both as @event.metadata.* and from the flattened event root.

Client-supplied metadata is unaffected: @event.metadata.reference and the like resolve exactly as before. The filter applies to the copy being read; the stored row keeps every key.

Adding a new internal key means adding it to internalTransferMetadataKeys — every one of the paths above iterates that list rather than naming keys, so one entry covers them all.

Finalize draft transfer (modern v2 flow)

POST /v2/transfers/{transfer_id}/finalize

Finalizes an existing v2 draft transfer. The client may either:

  • send the full CreatePaymentRequestV2 body again, or
  • omit the body and let the server reuse the finalize-shaped payload stored on the draft during POST /v2/transfers/create.

Repeated and concurrent finalize calls are deduplicated by the draft transfer_id, not by the caller-provided retry key. When a request body is sent, the caller key follows the normal header/body validation rules: mismatches return 400 Bad Request (common.invalid_input) with details[0].field = "idempotency_key" and details[0].rule = "header_body_mismatch", omitting both key locations returns the same field with details[0].rule = "required", and a key longer than 255 characters returns details[0].rule = "max" with details[0].param = "255". Empty-body finalize requests may omit both because the server reuses the stored draft payload. The finalized transfer is persisted with a server-derived draft-finalize: idempotency key. If two requests race, the transfers unique idempotency index selects one finalized transfer; the losing request re-reads that transfer. If the draft was already marked superseded, the API returns the same finalized transfer through the stored superseded link.

Finalize claims the draft under a row lock and reads the stored payload from that claimed snapshot, which serializes it against every draft mutation path. While the claim is held, an edit, cancel, or delete returns 409; if finalize fails, it releases the claim and the draft becomes editable again. This is why the finalized transfer can never be built from a payload that a concurrent edit has already replaced. A claim left behind by an interrupted attempt does not block a later finalize — the server-derived key makes every finalize of a given draft the same logical operation, so a retry re-claims and converges.

Each claim carries a token identifying the attempt that took it, and a release only clears the claim when the token still matches. When two finalize calls overlap, the later one re-claims and the earlier one's release becomes a no-op, so a failing attempt cannot unlock a draft that another attempt is still finalizing.

A draft is only finalizable while the counterparties it references are still active: source and destination are re-resolved at finalize time, so deactivating a counterparty makes an existing draft that references it fail with 404 on the affected field. Such a draft cannot be edited back into a valid state either, because editing re-resolves the same counterparty — cancel it and create a new draft instead.

The draft row and finalized row are not committed in one database transaction. Recovery is retry-based: if the finalized transfer was created but the draft was not yet linked, a retry uses the same server-derived key, resumes any pre-init finalized transfer if needed, then links the draft under a row lock. The draft link update is idempotent and preserves an existing _superseded_transfer_id.

The response uses the same TransferPaymentResponse shape as draft creation. The transfer_id in the finalize response is the finalized transfer id; if a repeated call resolves through the superseded draft link, the API returns the same finalized transfer projection.

Schedule transfer

POST /v2/transfers/scheduled

Current behavior: the handler intentionally responds with 501 Not Implemented and message code transfers_m.scheduled_worker_not_implemented (standard API error envelope). The scheduled-transfer execution worker and due-run persistence are not wired yet; accepting creates would leave transfers that never run. Use POST /v2/transfers/finalize for immediate execution today.

Planned contract (once implemented): same body as CreatePaymentRequestV2 plus a schedule object (frequency: never for one-shot future execution, or daily / weekly / biweekly / monthly for recurring templates); use metadata for client metadata; persist status = scheduled or status = recurring without running DSL init immediately; a worker executes due rows.

Legacy note: POST /v1/transfers/scheduled remains documented only for backward compatibility and is deprecated.

Transfer Quotes

Transfer quotes are short-lived, transfer-owned executable pricing snapshots. They lock the commercial economics that POST /v1/transfers/finalize and POST /v2/transfers/{transfer_id}/finalize consume via top-level quote_id, or that legacy POST /v1/transfers consumes through metadata.quote_id.

Pricing policy is server-owned. The quote request does not expose a raw FX provider/source selector. Instead, quote pricing must be anchored to transfer product context (product_code, product_id, or future server-side pre-check output that resolves a product) so the backend can resolve the authoritative pricing policy.

FX date resolution is also server-owned. Transfer quotes do not send a raw FX date selector in the normal flow; the FX module anchors quote pricing to the current business date in the configured timezone and may reuse the most recent stored rate found within the configured prior calendar-day window when fx.convert.default_rate_lookup_policy.max_lookback_days is greater than 0.

Product-owned pricing matters because the same currency pair can belong to different business products with different economics. For example, a payout product may legitimately price USDC -> EUR via one internal source, while an exchange product prices the same pair via another. The client chooses the product/business flow; the server chooses the internal pricing source.

File-seeded products declare the provider as meta.fx_source; product seeding derives Product.Settings.quote.fx_source from it. Quote creation does not fall back to DEFAULT, an empty-source lookup, or whichever provider currently has a rate. A missing declaration on a converting product stops product seeding, and an invalid setting on a legacy row makes quote creation fail with reason=fx_source_invalid.

GL booking legs inherit the same provider when their DSL uses a rate type such as rate = "MID-1ST". Legacy rate = "SOURCE|TYPE" expressions are accepted only when the source matches the product declaration. The resolved provider and rate type remain recorded on each journal entry as rate_source and rate_type.

For predictability and auditability, the persisted quote row stores key currency and pricing references as first-class columns in addition to the immutable price_snapshot JSON:

  • base_currency_id, target_currency_id, amount_currency_id reference currencies.currencies; currency code fields remain for readability and compatibility.
  • fx_rate_id, fx_spread_rule_id, fx_tariff_id, transfer_tariff_id, and fee-range IDs identify the pricing rules used for the quote.

The JSON snapshot remains the canonical customer-facing economic record, while scalar columns support reporting, reconciliation, and operational lookup without JSON parsing.

MVP scope:

  • POST /v2/transfers/quotes creates or reuses an active quote for the same fingerprint/idempotency key.
  • GET /v2/transfers/quotes/{quote_id} reads the quote snapshot by ID.
  • POST /v2/transfers/finalize and POST /v2/transfers/{transfer_id}/finalize consume a quote when top-level quote_id is present (copied to internal metadata).
  • Legacy POST /v1/transfers/finalize remains supported for existing clients.
  • POST /v1/transfers consumes a quote only when metadata.quote_id is present (legacy path).
  • Legacy transaction draft/confirm quote consumption is not part of this flow.

Create Transfer Quote

POST /v2/transfers/quotes

Create a quote using a minor-unit string amount. customer_id is required. amount.currency must match base. The backend explicitly calls FX conversion with amount_unit=minor and enriches the snapshot with transfer/product fee calculation when configured.

product_code or product_id is required for executable quote pricing. The API must not accept a caller-controlled FX source; the server resolves any internal pricing source from the product policy stored in Product.Settings.quote.fx_source.

Headers:

  • X-Idempotency-Key (optional): returns the existing active quote for the same key/fingerprint.

The transfer fee is priced on the product and the channel. The quote resolves product_code / product_id first and then asks the tariff for that product's fee (transaction_type = the product code) on the channel the request names, falling back to the wildcard when it names none — the same two keys POST /v1/products/pre-flight uses and the same ones the product's own rules use when they book. The matcher scores each dimension on its own, so a quote left on the wildcard while pre-flight pinned wire would select a different fee range: pass channel on the quote whenever the transfer has one.

A quote is bound to the channel it was priced on. A named channel is stored with the quote and compared on every path that consumes it — POST /v1/transfers/{id}/finalize and a transfer created with metadata.quote_id on POST /v1/transfers alike. A different channel is rejected 422 (transfers_m.quote_channel_mismatch). The fee the quote locked is the fee the transfer books, and a channel-scoped fee range answers a different price per channel.

Only a different channel is refused. A transfer that names no channel — channel is optional on POST /v1/transfers, which consumes a quote through metadata.quote_id — adopts the quote's, the channel the fee it is about to book was priced on. A caller that lets pre-flight choose the channel and does not echo it back therefore keeps working.

In the other direction the binding is deliberately absent: a quote locked without a channel is priced on the wildcard fee range and stays spendable on any channel, because binding it would make it unspendable through finalize, which requires a concrete channel, and because every quote created before the channel was recorded names none. Two consequences worth knowing: a channel-scoped transfer fee is enforced through the quote path only when the quote names the channel, so a caller that omits it locks the wildcard price; and pre-flight always passes the channel it was called with, so a quote locked through POST /v1/products/pre-flight with a channel carries it. The wildcard is not a binding: a quote created without a channel, or with any, is stored with none and stays spendable as before — a transfer may never present any as its own channel, so storing it would make the quote unusable. The comparison ignores case. It previously asked for transaction_type = the quote side (BUY/SELL); no tariff row is keyed on that, and the matcher treats an unknown transaction type as any, so on a tenant whose tariffs are scoped per product a quote was priced from an any row — or from no row at all — while pre-flight and the booking used the product's own fee. This changes the fee a quote charges wherever a product-scoped fee range exists.

A quote is priced by its fee bearer, and bound to it. fee_bearer on the request — DEBT/CRED/SHAR or pre-flight's OUR/BEN/SHA, any case — decides the figures: under DEBT/OUR the whole fee is charged on top of the amount (amount_to_pay = amount + fee) and the full amount converts; under CRED/BEN the whole fee comes out of the amount first (amount_to_convert = amount − fee); under SHAR/SHA the matched transfer fee range's sender_share_percent says how much the sender bears, the sender's part is rounded half up to the minor unit and the beneficiary bears the remainder. One share applies to the total fee, conversion fee included, because the bearer covers every charge of the transfer. The snapshot echoes fee_bearer (ISO spelling) and reports sender_fee and beneficiary_fee beside total_fee, which never moves. A SHAR quote on a tariff whose matched range declares no share is refused (422 tariffs.sha_share_not_declared, naming the tariff and the range) — who pays is a pricing decision, not something to assume 50/50.

The bearer is stored with the quote and compared on every path that consumes it, exactly like the channel: a transfer naming a different bearer is refused 422 (transfers_m.quote_fee_bearer_mismatch, both values in the ISO spelling), a transfer naming none adopts the quote's, and the bearer joins the pricing fingerprint only when present. A quote locked without a bearer prices as before — the whole fee out of the amount — hashes as it always did, and stays spendable under any bearer: that is what it promised, and it is every quote created before the field existed. It is spendable, but not overridable: the figures it locked are the ones the transfer books, so the product is priced for BEN — the bearer that arithmetic is — whatever bearer the transfer named. GET /v2/transfers/{id} answers the bearer the payment was booked under, so such a payment reads CRED even though it was finalized DEBT: a record that contradicts its own booking is the defect this change removes, not one to leave somewhere else. Pass fee_bearer whenever the figures shown must match the transfer; pre-flight passes the one it was called with into the quote it locks.

type is server-owned. A transfer quote always describes the customer selling base — the amount is denominated in base and is what they give up — so the server locks the SELL side and normalises a supplied BUY to it. The field remains required and must be BUY or SELL; anything else is rejected. This is what makes a quote locked through pre-flight and one locked here the same price with the same pricing fingerprint, so either caller can reuse the other's quote.

The pricing fingerprint includes the side and the channel, so two kinds of replay now produce a different fingerprint than they did before and are answered 409 (transfers_m.quote_idempotency_conflict) instead of returning the stored quote: a request created with type=BUY (now normalised to SELL), and a pre-flight call that pins a channel (which pre-flight passes through to the quote it locks, where it was absent before). Both affect only idempotency keys used before this change, and only until those quotes expire; issue a new key. A request that names no channel hashes exactly as it did before, so quotes created without one stay reusable.

Request Body:

{
  "customer_id": "123e4567-e89b-12d3-a456-426614174050",
  "base": "USDC",
  "target": "EUR",
  "type": "SELL",
  "amount": {
    "amount": "100000000",
    "currency": "USDC"
  },
  "product_code": "CRW"
}

Response (201 Created or 200 OK when reused):

{
  "id": "9fd9c26f-5737-4c71-8f45-8c9b2f38743d",
  "status": "quoted",
  "reused": false,
  "quoted_at": "2026-04-27T10:00:00Z",
  "expires_at": "2026-04-27T10:05:00Z",
  "price_snapshot": {
    "base": "USDC",
    "target": "EUR",
    "type": "SELL",
    "amount": {
      "amount": "100000000",
      "currency": "USDC",
      "precision": 6
    },
    "fx_source": "CRP",
    "selected_fx_rate": 0.9174,
    "fx_rate_date": "2026-04-25",
    "fx_rate_at": "2026-04-25T08:00:00Z",
    "market_rate_mid": 0.918,
    "amount_to_pay": {
      "amount": "100000000",
      "currency": "USDC",
      "precision": 6
    },
    "amount_to_convert": {
      "amount": "99500000",
      "currency": "USDC",
      "precision": 6
    },
    "amount_to_receive": {
      "amount": "9128",
      "currency": "EUR",
      "precision": 2
    },
    "total_fee": {
      "amount": "500000",
      "currency": "USDC",
      "precision": 6
    },
    "sender_fee": {
      "amount": "0",
      "currency": "USDC",
      "precision": 6
    },
    "beneficiary_fee": {
      "amount": "500000",
      "currency": "USDC",
      "precision": 6
    },
    "fees": [
      {
        "name": "conversion_fee",
        "amount": "300000",
        "currency": "USDC",
        "precision": 6
      },
      {
        "name": "transfer_fee",
        "amount": "200000",
        "currency": "USDC",
        "precision": 6
      }
    ],
    "fx_rate_id": "2bd29c44-8ceb-48a4-9038-2f238ebbfbc1",
    "tariff_ids": [
      "2c152f4d-738a-4ae3-91f2-861043343569"
    ]
  }
}

amount_to_receive is derived from amount_to_convert at the locked selected_fx_rate. amount_to_pay is amount plus sender_fee and amount_to_convert is amount minus beneficiary_fee, so the two fee parts — which always add up to total_fee — say which side of the transfer each charge lands on. The request above named no fee_bearer, so the whole fee is the beneficiary's share and amount_to_pay equals amount, which is how every quote was priced before the bearer applied; fee_bearer is echoed in the snapshot whenever the request named one.

fx_rate_date is the actual business date of the locked FX row. fx_rate_at is the original provider quote timestamp when the source exposes it. These fields can differ from quoted_at when FX lookback resolves an earlier stored rate date.

Get Transfer Quote

GET /v2/transfers/quotes/{quote_id}

Read a quote by ID. The path parameter is named quote_id; the response uses the client-facing id field.

Response (200 OK): same shape as quote create response.

Consume Quote in Create Transfer

Pass the quote ID through transfer metadata. The transfer amount and currency must match the quote request amount. Product/customer compatibility is validated before transfer creation.

{
  "product_code": "CRW",
  "type": "crypto_withdrawal",
  "txn_amt": 100000000,
  "txn_ccy": "USDC",
  "txn_paymentPurpose": "Withdraw USDC to EUR beneficiary",
  "ori_account_id": "123e4567-e89b-12d3-a456-426614174002",
  "ben_iban": "DE89370400440532013000",
  "ben_name": "John Doe",
  "metadata": {
    "quote_id": "9fd9c26f-5737-4c71-8f45-8c9b2f38743d"
  }
}

When the quote is consumed, the quote snapshot is copied into the transfer metadata under quote_price_snapshot, and quote-locked FX/fee fields are protected from later DSL FX/tariff recalculation overwrites.

The resolved internal quote-pricing source and FX rate provenance (fx_rate_date, fx_rate_at) are part of the quote economics and should be preserved in the locked quote snapshot / identity semantics for auditability and correct quote reuse behavior.

Payload Examples by Transfer Type

The following examples demonstrate how to create transfers for different product types. All amount fields use integers in minor units.

CRD - Crypto Deposit

Crypto deposit from an external wallet to a customer's wallet address.

Example: USDC Deposit (ERC20)

{
  "product_code": "CRD",
  "txn_amt": 1500000,
  "txn_ccy": "USDC",
  "txn_paymentPurpose": "Crypto deposit from external wallet",
  "ori_walletAddress": "0x742d35Cc6634C0532925a3b844Bc9e7515f0bEb",
  "ben_walletAddress": "0x555344432d455243323000000000000000000000",
  "bc_network": "ERC20",
  "token": "USDC",
  "bc_txHash": "0xabc123def4567890abcdef1234567890abcdef1234567890abcdef1234567890",
  "txn_externalId": "external-ref-126",
  "metadata": {
    "source": "external_deposit",
    "reference": "CRD-001"
  }
}

Key points:

  • txn_amt: 1500000 = 1.5 USDC (1,500,000 micro-USDC, 6 decimal places)
  • ben_walletAddress is required - system looks up customer by this address
  • bc_txHash is the on-chain transaction hash for tracking
  • No ori_account_id needed (external originator)

CRW - Crypto Withdrawal

Crypto withdrawal from a customer's account to an external wallet or converted to fiat.

Example: Crypto Withdrawal to External Wallet

{
  "product_code": "CRW",
  "txn_amt": 50123456,
  "txn_ccy": "USDC",
  "txn_paymentPurpose": "Withdrawal to personal wallet",
  "ori_account_id": "123e4567-e89b-12d3-a456-426614174002",
  "ori_walletAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb",
  "ben_walletAddress": "0x555344432d455243323000000000000000000000",
  "bc_network": "ERC20",
  "token": "USDC",
  "txn_instructionId": "CRW-2024-001"
}

Example: Crypto Withdrawal Converted to Fiat (IBAN)

{
  "product_code": "CRW",
  "txn_amt": 100000000,
  "txn_ccy": "USDC",
  "fee_mode": "OUR",
  "txn_feeAmt": 500000,
  "txn_netAmt": 100000000,
  "ben_amt": 9200000,
  "ben_ccy": "EUR",
  "txn_paymentPurpose": "Crypto withdrawal converted to EUR",
  "ori_account_id": "123e4567-e89b-12d3-a456-426614174002",
  "ben_iban": "DE89370400440532013000",
  "ben_name": "John Doe",
  "ben_bic": "COBADEFFXXX",
  "bc_network": "ERC20",
  "token": "USDC"
}

Key points (semantic model):

  • txn_amt: 100000000 = 100.0 USDC (debit from originator)
  • txn_ccy: "USDC" = debit currency
  • fee_mode: "OUR" = the sender bears the fee, charged on top of the amount, so the originator is debited 100.5 USDC
  • txn_feeAmt: 500000 = 0.5 USDC (fee in debit currency)
  • txn_netAmt: 100000000 = 100.0 USDC (what converts = txn_amt − the beneficiary's share, which is nothing under OUR)
  • ben_amt: 9200000 = 92.0 EUR (credit to beneficiary: 100.0 USDC converted at 0.92)
  • ben_ccy: "EUR" = credit currency

OWN - Internal Transfer Between Own Accounts

Transfer between two accounts owned by the same customer.

Example: Transfer Between Own Accounts

{
  "product_code": "OWN",
  "txn_amt": 5000000,
  "txn_ccy": "EUR",
  "txn_paymentPurpose": "Transfer from savings to checking account",
  "ori_account_id": "123e4567-e89b-12d3-a456-426614174002",
  "ben_account_id": "123e4567-e89b-12d3-a456-426614174003",
  "txn_instructionId": "OWN-2024-001"
}

Key points:

  • txn_amt: 5000000 = 50,000.00 EUR (5,000,000 cents)
  • Both ori_account_id and ben_account_id are required
  • Same customer owns both accounts

INT - Internal Transfer (Within Core Banking System)

Transfer between accounts within the core banking system (different customers).

Example: Internal Transfer Between Customers

{
  "product_code": "INT",
  "txn_amt": 100050,
  "txn_ccy": "EUR",
  "txn_paymentPurpose": "Payment for services",
  "ori_account_id": "123e4567-e89b-12d3-a456-426614174002",
  "ben_account_id": "123e4567-e89b-12d3-a456-426614174003",
  "txn_instructionId": "INT-2024-001"
}

Key points:

  • txn_amt: 100050 = 1,000.50 EUR (100,050 cents)
  • Both accounts are within the same banking system
  • Different customers (originator and beneficiary)

IWT - Incoming Wire Transfer

Incoming wire transfer from an external bank account (IBAN) to a customer's account.

Example: Incoming Wire Transfer

{
  "product_code": "IWT",
  "txn_amt": 250075,
  "txn_ccy": "EUR",
  "txn_paymentPurpose": "Invoice payment - Invoice #INV-2024-001",
  "ori_name": "Acme Corporation",
  "ori_iban": "DE89370400440532013000",
  "ori_bic": "COBADEFFXXX",
  "ben_account_id": "123e4567-e89b-12d3-a456-426614174002",
  "ben_iban": "CH9300762011623852957",
  "ben_bic": "UBSWCHZH80A",
  "txn_externalId": "ext-wire-001"
}

Key points:

  • txn_amt: 250075 = 2,500.75 EUR (250,075 cents)
  • ori_iban and ori_bic identify the external sender
  • ben_account_id identifies the receiving account
  • No ori_account_id needed (external originator)

OWT - Outgoing Wire Transfer

Outgoing wire transfer from a customer's account to an external bank account (IBAN).

Example: Simple Outgoing Wire Transfer

{
  "product_code": "OWT",
  "txn_amt": 100000,
  "txn_ccy": "USD",
  "txn_paymentPurpose": "International wire transfer",
  "ori_account_id": "123e4567-e89b-12d3-a456-426614174002",
  "ori_name": "Alice Smith",
  "ori_iban": "US64SVBKUS6S3300958879",
  "ori_bic": "SVBKUS6S",
  "ben_name": "Bob Johnson",
  "ben_iban": "DE89370400440532013000",
  "ben_bic": "COBADEFFXXX",
  "txn_instructionId": "OWT-2024-001"
}

Example: Outgoing Wire with Fees and FX Conversion

{
  "product_code": "OWT",
  "txn_amt": 100000,
  "txn_ccy": "USD",
  "fee_mode": "OUR",
  "txn_feeAmt": 450,
  "txn_netAmt": 100000,
  "ben_amt": 92025,
  "ben_ccy": "EUR",
  "txn_paymentPurpose": "International wire transfer with fees and FX",
  "ori_account_id": "123e4567-e89b-12d3-a456-426614174002",
  "ori_name": "Alice Smith",
  "ori_iban": "US64SVBKUS6S3300958879",
  "ori_bic": "SVBKUS6S",
  "ben_name": "Bob Johnson",
  "ben_iban": "DE89370400440532013000",
  "ben_bic": "COBADEFFXXX",
  "txn_instructionId": "OWT-2024-002"
}

Key points (semantic model):

  • txn_amt: 100000 = 1,000.00 USD (debit from originator)
  • txn_ccy: "USD" = debit currency
  • fee_mode: "OUR" = the sender bears the fee, charged on top of the amount, so the originator is debited 1,004.50 USD
  • txn_feeAmt: 450 = 4.50 USD (fee in debit currency)
  • txn_netAmt: 100000 = 1,000.00 USD (what converts = txn_amt − the beneficiary's share, which is nothing under OUR)
  • ben_amt: 92025 = 920.25 EUR (credit to beneficiary: 1,000.00 USD converted at 0.92025)
  • ben_ccy: "EUR" = credit currency

List Transfers

GET /v1/transfers

List transfers with filtering, sorting, and pagination support. Uses RBAC-aware query system with JSON-based search and sort parameters.

This list shows payments, one row per payment, and never a beneficiary's own row. A company on the receiving side of a payment made here is shown the payment itself, so it reads as outgoing, with the payer as originator; customer_id on this list has always meant the originator. That is unchanged for a company reading its own outgoing payments, and it is one row per payment either way — a payment carrying the old type = "internal" beneficiary row used to appear here twice.

To read the receiving company's own record, ask for it on v2: search.customer_id on GET /v2/transfers, or customer_id on GET /v2/transfers/{transfer_id}. See Beneficiary legs.

Query Parameters

The API supports dot notation for search parameters (e.g., search.status, search.product_code).

  • search.{field} (optional): Filter by field using dot notation

  • Examples: search.status=pending, search.product_code=CRD, search.txn_ccy=EUR

  • Supported fields: status, type, ori_customer_id, ben_customer_id, product_id, product_code, txn_ccy (currency), created_at, updated_at

  • Note: product_code can be used as an alternative to product_id. If the product code is not found, the query returns no results.

  • sort (optional): Sort field (prefix with - for descending)

  • Examples: sort=-created_at, sort=created_at

  • Supported fields: id, ori_customer_id, ben_customer_id, product_id, type, status, amount, currency, created_at, updated_at

  • limit (optional): Number of items per page (default: 20, max: 100)

  • offset (optional): Starting position for pagination (default: 0)

Example Request

GET /v1/transfers?limit=20&offset=0&search.status=pending&search.product_code=CRD&search.txn_ccy=EUR&sort=-created_at

Response (200 OK):

{
  "data": [
    {
      "id": "123e4567-e89b-12d3-a456-426614174100",
      "product_id": "123e4567-e89b-12d3-a456-426614174001",
      "product_code": "CRD",
      "ori_customer_id": "123e4567-e89b-12d3-a456-426614174050",
      "type": "sepa",
      "status": "pending",
      "active": true,
      "txn_amt": { "amount": "100000", "currency": "EUR", "precision": 2 },
      "created_at": "2024-03-21T10:00:00Z",
      "updated_at": "2024-03-21T10:00:00Z"
    }
  ],
  "total": 1,
  "total_unfiltered": 5,
  "has_more": false
}

metadata is added alongside these when the request used search._text.* — it carries the text-search properties the parser resolved, and the handler builds it itself.

keys never appears on this route, and neither does a stacked data object: stack is read by the shared parser but Service.GetTransfers never fills StackedData or Keys, so the v1 list always answers with the flat array above. GET /v2/transfers is the transfers list that honours stack; /v1/transfers/statuses and /v1/transfers/events honour it too.

Get Transfer by ID

GET /v1/transfers/{transfer_id}

Retrieve a single transfer by its ID with full details.

Response (200 OK):

{
  "id": "123e4567-e89b-12d3-a456-426614174100",
  "product_id": "123e4567-e89b-12d3-a456-426614174001",
  "product_code": "CRD",
  "ori_customer_id": "123e4567-e89b-12d3-a456-426614174050",
  "ori_account_id": "123e4567-e89b-12d3-a456-426614174002",
  "type": "sepa",
  "status": "pending",
  "active": true,
  "txn_amt": { "amount": "100000", "currency": "EUR", "precision": 2 },
  "txn_paymentPurpose": "Payment for invoice #12345",
  "ori_name": "John Doe",
  "ori_iban": "CH9300762011623852957",
  "ori_bic": "UBSWCHZH80A",
  "ben_name": "Acme Corp",
  "ben_iban": "DE89370400440532013000",
  "ben_bic": "COBADEFFXXX",
  "created_at": "2024-03-21T10:00:00Z",
  "updated_at": "2024-03-21T10:00:00Z"
}

Response (404 Not Found): message code common.record_not_found

{
  "status": 404,
  "message": "Record not found"
}

A record-level grant outlives the row it was issued for, so a caller that once had access to a since-deleted transfer still passes the RBAC check and is only then told the transfer is missing. GET /v2/transfers/{transfer_id} and PUT /v2/transfers/{transfer_id} answer the same way.

Update Transfer

PUT /v1/transfers/{transfer_id}

Update a draft transfer. Only transfers in draft status can be updated.

Request Body:

{
  "amount": 150000,
  "currency": "EUR",
  "description": "Updated payment description",
  "ben_name": "Updated Recipient Name"
}

Note: The amount field must be an integer in minor units (e.g., 150000 for €1,500.00 EUR, or 2500000 for 2.5 USDC).

Cancel Transfer

PATCH /v1/transfers/{transfer_id}/cancel

Cancel a pending transfer. Validates status transitions before cancellation.

Request Body:

{
  "reason": "Customer requested cancellation"
}

Delete Transfer

DELETE /v1/transfers/{transfer_id}

Delete a draft transfer. Only transfers in draft status can be deleted.

Get Status History

GET /v1/transfers/{transfer_id}/status-history

Retrieve the status change history for a transfer using the standard get_all format.

This endpoint is a convenience alias of GET /v1/transfers/statuses with search.transfer_id forced from the path.

Response (200 OK):

{
  "data": [
    {
      "id": "c7fd8bb5-4b9c-4a40-a83b-9d8f3b0cc3a3",
      "transfer_id": "123e4567-e89b-12d3-a456-426614174100",
      "created_at": "2026-01-15T13:43:52.268843+01:00",
      "created_by": "00000000-0000-0000-0000-000000000000",
      "prev_status": "draft",
      "status": "pending",
      "internal": false
    }
  ],
  "total": 1,
  "total_unfiltered": 1,
  "has_more": false
}

List Transfer Status Changes

GET /v1/transfers/statuses

List transfer status changes using the standard get_all format (supports search.*, sort, order via sort=-field, limit, offset, and stack).

Query Parameters

  • search.{field} (optional): filter by fields of the status record
    • Common examples: search.transfer_id=, search.status=pending, search.internal=false
  • sort (optional): sort field (prefix with - for descending)
    • Examples: sort=-created_at, sort=transfer_id
  • limit (optional): number of items per page (default: 20, max: 100)
  • offset (optional): starting position for pagination (default: 0)
  • stack (optional): return stacked data instead of a flat list (e.g. stack=status or stack=created_at[YYYY-MM-DD])

Example Request

GET /v1/transfers/statuses?limit=20&offset=0&search.transfer_id={transfer_id}&sort=-created_at

List inbound payments

GET /v1/transfers/inbound-payments

Lists every payment the payment rail gateway (Zahlex) reported for the bank — money received for one of our customers, and returns of payments we sent — with what CoreBanq did about it. This is the operator's queue for money that arrived but could not be booked automatically; nothing the rail reports is dropped silently.

Who uses it. Payment operations and support. Administrator accounts only: an unattributed receipt is the bank's business, not a customer's.

Query parameters. status narrows the list; limit (default 50, at most 200) and offset page through it. Newest first.

What a row tells you.

FieldMeaning
kindreceived — a credit for one of our customers; return — a payment we sent came back
statusbooked — an incoming transfer was created for the matched customer and account; unmatched — no single customer account could be named, parked for an operator; returned_unbooked — a return of a settled payment, recorded and never booked automatically; failed — matched, but the incoming transfer was refused; received — recorded, attribution still in progress
reasonwhy a row is parked or failed, in the words the system used: no_structured_reference, no_open_invoice, ambiguous_reference, account_not_found, account_customer_mismatch, reference_too_broad (the reference matches more open invoices than can be told apart), payment_not_readable_at_gateway (the gateway does not know the payment or will not show it to our key — a payment recorded on another tenant reads as not found), currency_mismatch (the payment's currency differs from the invoice's or from the account's — the product is never asked to convert a credit), original_not_resolved, or the create failure
payment_id, external_idthe gateway's payment number and the counterparty's own reference
amount_minor, currencywhat arrived, in minor units
structured_reference, remittance_infothe reference the payer quoted — the only attribution key the gateway exposes
transfer_id, customer_id, account_idfilled when the payment was booked (or matched before a failure)
original_payment_id, original_transfer_idon a return: the payment we sent and the transfer behind it

How a receipt is attributed. The structured reference the payer quoted (a QR reference or an ISO creditor reference, at least five characters) is matched against the bank's open invoices — issued, sent, pending or overdue. Exactly one match, whose account IBAN is one of our active internal accounts belonging to that invoice's customer, is booked as an inward transfer (IWT) for that customer and account, with the same amount and currency the rail reported. Anything else is parked with a reason; nothing is guessed. Returns are always parked: the original transfer is already completed and the entries are an operator's decision.

When a booking fails. A transient database fault while the transfer is created, such as a deadlock or a lost connection, is not a refusal: the row stays received and the queue redelivers the payment. Any other database error on the insert, such as a value too long for its column or a violated constraint, repeats on every attempt, so the row is marked failed with the transfers_m.database_error text as its reason. The database's own message is in the service log under the row's payment_id. A fault that outlasts the queue's redeliveries leaves the row received with no reason; a received row that is not recent needs an operator.

List Available Events

GET /v1/transfers/events

List transfer event rows using the standard get_all format (supports search.*, sort (with order via sort=-field), limit, offset, and stack).

A failed event is recorded too, with from_status equal to to_status — the transfer does not move. Its transaction has rolled back, so the successful hops that preceded it leave no rows of their own, but their ledger postings were committed on the ledger's own connection and remain: this row is what names the event that failed, the error, and the side effects that had already been applied. The transfer is deliberately left at its current status rather than marked failed or reversed, both of which are terminal and would block an operator from driving the product's own reverse path.

Query Parameters

  • search.{field} (optional): filter by fields of the event record
    • Common examples:
      • search.transfer_id={transfer_id}
      • search.event_type=before-kyt
      • search.event_type.nin=status_change (exclude status change rows)
      • search.created_at.gte=2026-01-01 00:00:00 +00:00
  • sort (optional): sort field (prefix with - for descending)
    • Examples: sort=-created_at, sort=event_type
  • limit (optional): number of items per page (default: 20, max: 100)
  • offset (optional): starting position for pagination (default: 0)
  • stack (optional): return stacked data instead of a flat list (e.g. stack=event_type or stack=created_at[YYYY-MM-DD])

Example Request

GET /v1/transfers/events?limit=20&offset=0&search.transfer_id={transfer_id}&search.event_type.nin=status_change&sort=-created_at

Response (200 OK):

{
  "data": [
    {
      "id": "2f4b3cf3-74b0-449e-8084-60ae8131a7a7",
      "transfer_id": "123e4567-e89b-12d3-a456-426614174000",
      "created_at": "2026-01-15T13:43:53.090265+01:00",
      "created_by": "123e4567-e89b-12d3-a456-426614174111",
      "event_type": "before-kyt",
      "from_status": "draft",
      "to_status": "pending",
      "payload": {},
      "context": {},
      "dsl_trace_id": "trace_123",
      "error_code": "",
      "error_message": ""
    }
  ],
  "total": 1,
  "total_unfiltered": 1,
  "has_more": false
}

List Transfer Events

GET /v1/transfers/{transfer_id}/events

List executed events for a specific transfer. Returns event definitions with dynamic labels extracted from the transfer's DSL context.

Response (200 OK):

{
  "transfer_id": "123e4567-e89b-12d3-a456-426614174100",
  "events": [
    {
      "id": "d87843f8-1bc1-4d05-a9bd-c5cf968fdba3",
      "key": "init",
      "name": "Init",
      "description": "Transfer creation",
      "status": "executed",
      "can_rerun": false,
      "created_at": "2026-01-20T14:17:38.766903+01:00",
      "context": {}
    },
    {
      "id": "42fd9236-70b1-44ff-baad-297d6e2646bd",
      "key": "before-kyt",
      "name": "Approve the document",
      "description": "Approve the document",
      "status": "executed",
      "can_rerun": true,
      "created_at": "2026-01-20T14:17:39.009568+01:00",
      "context": {}
    }
  ]
}

Notes:

  • Event labels (name and description) are dynamically extracted from the transfer's context events:kv-list assignment (defined in DSL init event)
  • Only init and complete events have hardcoded labels; all other events use DSL-defined labels
  • If no label is found in DSL, the event type code is used as the display name
  • Only successfully executed events (no errors) are returned
  • The init event cannot be rerun; all other events can be rerun by default
  • events is null, not [], when the transfer has no successfully executed non-status_change event yet — the handler appends onto a nil slice, and Go marshals that as null

Get Event Details

GET /v1/transfers/{transfer_id}/events/{event_type}

Not implemented — see "Not-implemented endpoints" below. The route is registered and authenticated, and then answers 501. Nothing previews what an event would execute.

Execute Event

POST /v1/transfers/{transfer_id}/events/{event_type}/execute

Manually trigger a DSL event for a transfer. This one is real, and it executes — there is no dry-run switch on the wire.

Request Body: optional, and free-form. The handler decodes whatever is sent into a map[string]any and passes it to the DSL as the event payload. A body such as {"dry_run": true} does not simulate anything: the event runs, and its ledger postings are committed. See "Executing an event answers 200, and its body is free-form" below for the response shape and the statuses it can raise.

{
  "answer": "approved",
  "comment": "reviewed by operations"
}

Set Manual FX Rate

PATCH /v2/transfers/{transfer_id}/fx-rate

Not implemented — see "Not-implemented endpoints" below. SetManualFXRate ignores the request and answers 501; there is no manual-rate field, no reason field, and no operator permission check behind it. The FX rate a transfer uses comes from its quote.

Sign Transfer

POST /v1/transfers/{transfer_id}/sign

Not implemented — see "Not-implemented endpoints" below. SignTransfer ignores the request and answers 501. No signature is stored or verified anywhere in this module.

Not-implemented endpoints

Four handlers in this module ignore the request and answer 501 with a bare {"error": "Not implemented yet"} map — not the apireply envelope, and the key is error, not message:

MethodPath
POST/v1/kyt/webhook
GET/v1/transfers/{transfer_id}/events/{event_type}
POST/v1/transfers/{transfer_id}/sign
PATCH/v2/transfers/{transfer_id}/fx-rate

POST /v1|v2/transfers/scheduled is also a 501, but a different one — it goes through the envelope with transfers_m.scheduled_worker_not_implemented.

The 501 is not unconditional on three of the four. sign, the event detail and fx-rate go through auth.WrapWithMiddlewares, so a missing, malformed or expired bearer token is refused with 401, and an RBAC or licence denial with 403, before the stub body is ever produced. Only the KYT webhook is registered bare — see below for what still refuses it.

Every v1 transfer response is the minor-units shape

Handlers convert every v1 transfer payload through toMinorUnitsResponse, so:

  • txn_amt, ben_amt, txn_netAmt and txn_feeAmt are CcyAmtWithPrecision objects — {amount, currency, precision} — where amount is a string of integer minor units. Never parse it as a float; minor-unit values exceed what a JSON number carries losslessly.
  • There is no top-level txn_ccy; the currency lives inside txn_amt.
  • The description is serialised as txn_paymentPurpose.
  • active is present.

This applies to POST /v1/transfers, POST /v1/transfers/finalize, GET /v1/transfers, GET /v1/transfers/{transfer_id}, PUT/PATCH /v1/transfers/{transfer_id}, PATCH .../cancel, and the nested transfer in POST .../events/{event_type}/execute.

Transfer-scoped events are not the get_all envelope

GET /v1/transfers/{transfer_id}/events is not a convenience alias of GET /v1/transfers/events. It answers {transfer_id, events[]} with no data, total, keys or pagination, and reads no query parameter — Accept-Language is still honoured, since the error envelopes are localised like every other route's. Only events that already executed appear; status_change rows are excluded. The list is sorted oldest-first.

Executing an event answers 200, and its body is free-form

POST /v1/transfers/{transfer_id}/events/{event_type}/execute returns 200, not 201, with:

{ "transfer": { }, "result": { }, "question": { } }

transfer is the minor-units shape, result is the raw DSL execution result, and question is present only for a DSL QUESTION outcome. The request body is an optional free-form JSON object — a structured body with dry_run, force, reason or a nested payload wrapper is read by nothing.

The statuses this route can answer are not a closed set. runTransferEvent propagates a DSL action's own *errs.AppError unchanged, so an error raised inside a call{} interface or a gl-batch surfaces with whatever status it carried; only a non-AppError failure is flattened to 500 dsl_m.execution_failed. The three a gl-batch reaches through the ledgers module are:

  • 402 ledgers.insufficient_funds against a ledger whose spendable balance does not cover the posting
  • 409 ledgers.ledger_inactive / ledgers.ledger_closed from EnsureLedgerPostable on the journal path
  • 422 from the transit-ledger currency guards, and from the tariff service a tariff{} step calls

A product that calls another interface can surface a status none of those lists.

The 400s the handler itself raises are an unparseable transfer_id, a missing event_type, an undecodable body, a terminal transfer (transfers_m.cannot_execute_event_terminal_transfer) and a repeat of an event the product does not allow to repeat (transfers_m.cannot_execute_same_event_consecutive).

An unknown transfer_id is never a 404 on the five v1 write routes

PUT and PATCH /v1/transfers/{transfer_id}, PATCH .../cancel, DELETE .../{transfer_id} and POST .../events/{event_type}/execute all load the transfer and then discard the storage layer's errs.MsgNotFound, returning errs.New(errs.MsgDatabaseError) with no code — which HandleAppErrorWithCode maps to 500.

Which status a caller actually sees depends on the RBAC check that runs before the read. An id that never existed has no grant, so rbac.RecPermission refuses it with 403. The 500 is what a caller who passes that check gets: an administrator, whom RecPermissionCtx waves through without touching the row, or the holder of a record grant that outlived the row it was issued for — the same situation the GET section above describes.

Only GET /v1/transfers/{transfer_id} (and the routes that call it first: .../status-history, .../events, /v1/transfers/context) runs the miss through storageReadError and answers 404. Do not branch on 404 on the five write routes.

KYT Webhook

POST /v1/kyt/webhook

Not implemented, and unauthenticated. The handler ignores the request entirely and answers 501 with a bare {"error": "Not implemented yet"} map — not the apireply envelope, and note the key is error rather than message. The request body below describes what the provider is expected to send, not anything the server currently reads.

It is also the only route in this module registered with a bare HandlerFunc instead of auth.WrapWithMiddlewares: no bearer token, no RBAC check, no licence check, and no signature verification of the body. It can never answer 403.

The 501 is still not the only answer it gives. auth.RateLimitMiddleware sits on the root router ahead of the route and refuses requests before the stub runs, whenever rate_limits.rate_limits_switcher is on:

  • 429 with no token at all. The anonymous branch increments rate_limit:ip_global::POST:/v1/kyt/webhook and answers 429 both past the global IP limit and when the increment itself fails — the ordinary shape of a provider retrying its callbacks from one address.
  • 401 with a token. The limiter parses the Authorization header itself via checkAuthorization, ahead of auth.Middleware. A valid token takes its authenticated branch into findMatchingEndpoint, which returns "permission denied" WithCode(401) when no api_permission row matches — and a route registered bare has no such row. errs.New accepts a plain string as well as a MsgCode and stores it as AppError.Key, which is what MachineCode() returns — so code is not empty, it is the same free-text sentence as message. Matchable, but not a dotted key and never translated.
  • 500 with a token. The same authenticated path answers 500 common.server_error when the limiter's own lookups fail; handleRateLimitError writes that one without the AppError, so the specific keys are discarded.

With the switcher off the limiter is not in the chain and none of those three can happen. The graceful-shutdown 503 comes from a different middleware — the root router's, ahead of authentication — and is reachable either way; its body is a bare {overall_status, message, timestamp} map rather than the envelope. Restrict the route at the ingress if it is exposed at all — the application does not.

The path also sits outside the module's own prefix: /v1/kyt/webhook, not /v1/transfers/….

Request Body:

{
  "data": {
    "tx_id": "123e4567-e89b-12d3-a456-426614174100_kyt",
    "tx_type": "transfer",
    "tx_amount": 100000,
    "tx_currency": "EUR"
  },
  "alerts": [
    {
      "action": "Hard Stop",
      "state": {
        "label": "Not Suspicious"
      }
    }
  ]
}

DSL Integration

Event Types

The transfers module uses a fully DSL-driven event system. Only two events are static/system events:

EventTypeDescriptionTypical Actions
initSystemTransfer creation (automatically triggered on transfer creation)Validate, book initial GL entries, set status, define available events
completeSystemTransfer completionFinal GL entries, notifications

All other events are dynamic and DSL-defined. Event names, descriptions, and workflows are configured in the Product's DSL rules via the events:kv-list assignment in the init event:

when evaluate $params.event_id equals "init" then sequence {
  assign events:kv-list = {
    "before-kyt": "Approve the document",
    "kyt-green": "Transaction is good to go",
    "kyt-red": "Transaction is flagged"
  }
  assign events_next:kv-list = {
    "before-kyt": "kyt-green,kyt-red"
  }
  route event = "before-kyt"
}

Event Labels and Descriptions:

  • Event labels (display names) are extracted from the events:kv-list assignment in the transfer's context
  • The GET /v1/transfers/{transfer_id}/events endpoint dynamically reads these labels from the transfer context
  • If no label is defined in DSL, the event type code is used as the display name
  • Only init and complete have hardcoded labels ("Init" and "Complete")

Event Execution:

  • Events can be triggered manually via POST /v1/transfers/{transfer_id}/events/{event_type}/execute
  • The same event cannot be executed twice consecutively (backend validation)
  • Events are recorded in transfers.events table with full execution context

Payment rail gateway (Zahlex)

Purpose. Outgoing wires (the OWT product) are handed to Zahlex, the bank's Swiss payment rail gateway, and followed until the rail settles or refuses them. The product rule makes the call and decides what happens next; CoreBanq never triggers the send itself.

Who uses it. Payment operations watching outgoing wires, support explaining a payment's state to a customer, and the operator who switches between demo mode and the real gateway.

How it works. Once screening has passed (kyt-green), the product asks the gateway to create the payment. Zahlex answers accepted — taken, and on its way to the rail — or rejected, with a reason. accepted is not success: the transfer stays pending (event "Handed to the payment rail - awaiting settlement") and no settlement entry is booked; the funds sit on the suspense account the pre-screening entries already moved them to. Each later status message from Zahlex resumes the product through its payment-status event:

Zahlex reportsThe transferBookkeeping
accepted, submitted, pending, held, querying, expired_unsettledstays pendingnone
settledsettlement entries, then completedthe OWT settlement batch
rejected, cancelled, expiredreversedcustomer's money and fees back: the product books the refund from suspense to the customer's account (ledger event rail-refused) before it reverses
a return of a settled paymentunchanged (completed); the return is parked as an inbound paymentnone until an operator decides
recall requested / resolved, reconciliation breakunchanged; acknowledged and logged, nothing is written to the transfernone

A missing or unreadable answer never books anything: the product waits.

Status messages. Zahlex delivers signed messages to corebanq.listener (route /webhooks/zahlex); CoreBanq takes them from the queue, verifies the signature over the message as received (five-minute replay window, two secrets accepted during a rotation), ignores a repeated message, ignores one that arrives out of order, and leaves a finished transfer alone. The signing secret on our side (payment_provider.zahlex.webhook_secret) must equal the value Zahlex runs with (COREBANQ_PAYMENT_WEBHOOK_SECRET), set on both sides before Zahlex first starts. The queue consumer runs only while the real gateway is selected.

Three facts about delivery that decide what an operator sees. The first status messages (validated, accepted) can arrive before our own create call has returned; each of them names the payment, so a transfer that stores no payment yet adopts the payment number from the first message that reaches it; a message naming a payment other than the one the transfer is bound to is refused and logged (payment_id_mismatch) and leaves the transfer for an operator. A message that fails for a passing reason (database unavailable, the transfer still locked by a running step) is redelivered by the queue at once and without a cap until it applies, so a sustained failure shows as a growing error counter on the consumer, not as a lost message; Zahlex, for its part, answers a caller over its per-key rate limit with 429. A message whose run had already posted entries and then failed is remembered by the engine under the message id and is not run again: the transfer's event list carries the failure and an operator resumes it.

Configuration. One value switches the gateway: payment_provider.driver — mock (default, demo mode) or zahlex. It is read on every call; no restart. The zahlex driver needs payment_provider.zahlex.base_url and payment_provider.zahlex.api_key (a Zahlex machine key with the payments:write and payments:read scopes, minted in live mode: while Zahlex's automatic submission is on, its create call refuses a test-mode key, because creating the payment now reaches the rail) and refuses to work half-configured: every payment is then held with the reason "payment gateway not configured". payment_provider.zahlex.timeout and payment_provider.zahlex.max_retries bound the call — retries reuse the same idempotency key, so Zahlex replays instead of creating twice. Party countries go to the rail as two-letter ISO codes: the three-letter code our customer addresses store (CHE) is converted through the countries registry, and a value that is neither is left out and logged rather than sent — the rail refuses the whole payment over a country of the wrong shape. payment_provider.zahlex.queue.* names the RabbitMQ queue the listener publishes to. Two facts about that path decide whether anything arrives: the listener publishes a message for /webhooks/zahlex with the routing key webhooks.zahlex (its own derivation from the path), and the consumer binds zahlex.webhooks to the webhooks exchange under that key itself when it starts — so a queue that receives nothing means the two sides disagree on the key, not a missing binding. On the stand the transfers module logs at warn, so the consumer's per-message lines (info) appear only when the level is raised.

Bringing the link up, step by step. Nothing below is needed in demo mode (driver: mock).

  1. From Zahlex. The API base URL; a machine API key minted in live mode with the payments:write and payments:read scopes, on the tenant that represents this bank on their side — the same tenant their receiving-participant registry names for incoming payments (step 6), since the key can read only that tenant's payments; and agreement on the webhook: Zahlex registers our endpoint (their settings COREBANQ_PAYMENT_WEBHOOK_URL, COREBANQ_PAYMENT_WEBHOOK_SECRET, COREBANQ_PAYMENT_WEBHOOK_ENABLED) before their first start — a blank secret at first start is replaced by one Zahlex generates and never shows, and only a rotation on their side recovers it. Their egress guard refuses a non-public webhook address (loopback, private or link-local), so the listener must be reachable on a public name.
  2. corebanq.listener. Route /webhooks/zahlex with the raw body kept (include_raw_body: the signature is computed over the bytes as received), publishing to the RabbitMQ exchange webhooks. The listener derives the routing key from the path: webhooks.zahlex, with no webhook. prefix, because the path already starts with webhook.
  3. CoreBanq configuration (payment_provider block): zahlex.base_url, zahlex.api_key (secret), zahlex.webhook_secret equal to the value from step 1 (zahlex.webhook_secret_previous during a rotation), zahlex.timeout and zahlex.max_retries, and zahlex.queue.* — the broker (host, port, username, password, virtual_host), exchange: webhooks, queue_name: zahlex.webhooks, routing_key: webhooks.zahlex, listener_route: /webhooks/zahlex, prefetch_count and worker_count. The consumer binds its queue to the exchange under that routing key itself.
  4. Products. The outgoing product must be a version with the payment-status step (OWT 2.0.10 or later; 2.0.9 has no way to receive a rail status), and the incoming product IWT must be active for step 6. ZLXT is the smoke product for a first payment.
  5. Switch and restart. Set payment_provider.driver: zahlex. Gateway calls read the driver on every call, but the webhook consumer is started with the API and only when the driver is zahlex at that moment — restart the API after the switch, and check the log line that the consumer started. Then send one small payment and follow it: the create call answers accepted and the transfer stays pending; the rail's messages arrive through the listener and the transfer completes on settled by itself. If nothing arrives, check in this order: Zahlex's delivery log for our endpoint (4xx there is the listener refusing, pending, attempts=0 is their dispatcher not trying), the exchange binding (rabbitmqctl list_bindings must show zahlex.webhooks under webhooks.zahlex), and the consumer's counters. A manual payment-status on the pending transfer proves the key and the base URL independently of the webhook path.
  6. Before the first incoming payment. Zahlex attributes an incoming credit to a tenant from the receiving bank's identification in the interbank message — the institution digits of our IBANs (the five-digit IID, 83064 for the stand's accounts) — through its receiving-participant registry. That registry must map our institution to the tenant our API key belongs to; otherwise every incoming payment reads back as not found and is parked as payment_not_readable_at_gateway. On our side an incoming payment is booked only when it quotes a structured reference of an open invoice: for a standard IBAN that is an ISO 11649 creditor reference (RF…), since Zahlex's ingest accepts a QR reference only together with a QR-IBAN (institution 30000–31999); a reference shorter than five characters names nothing. The invoice's IBAN must be an active internal account of the invoice's customer, and the payment, the invoice and the account must agree on the currency. Everything else is parked with a reason and shows up in GET /v1/transfers/inbound-payments — watch that list from the first day.

Demo mode. With driver: mock no rail is contacted and every payment is answered as settled at once, exactly as before this gateway existed. For tests and demonstrations a request (or the transfer's metadata) may carry mock_payment_status to force the other answers the real rail gives: pending — accepted, the transfer waits and a manual payment-status settles it; failed or rejected — a business refusal, the transfer reverses (rejected carries the ISO 20022 reason code AC01 unless mock_reason_code names another; failed carries mock_reason_code only when given); invalid — the rail cannot carry a value, the same wording as Zahlex's 422 with (ben_iban: swiss_iban) unless mock_reason_code names another field and rule, and the transfer reverses with it; unavailable — the rail did not answer, the payment is held with outcome: unknown and waits for a manual payment-status, which in demo mode finds no payment and keeps waiting (the operator path an outage leaves behind); conflict — the idempotency key was already used with another payload, held the same way. INT and OWN products never call the gateway and are unaffected by the setting.

What support reads on a parked payment. The transfer's context carries the last gateway answer under payment: payment_id (the Zahlex payment number), payment_status (Zahlex's word), reason_code, and outcome. outcome: unknown means Zahlex did not answer — the payment may or may not exist. In the Zahlex console our payment carries external_id = cbq-.

Resuming a payment by hand. Execute the transfer's payment-status event (see Execute Event). It reads the live status by the payment number; with no number known it looks the payment up by our reference, and when nothing matches it stays pending with the reason "payment not found at gateway" — contact Zahlex operations rather than re-sending. The step never creates a second payment. The rail's facts (payment_status, payment_id, payment_sequence, reason_code) and the stored answer payment come from the rail's own messages alone, and the screening verdict kyt the create envelope carries comes from the product's own screening step alone: a request body or transfer metadata naming any of them is ignored and logged, so nobody can settle or reverse a payment — or release a screening hold towards the rail — by assertion.

Payments that stay accepted. The send to the rail is Zahlex's automatic submission (their setting SIX_AUTO_SUBMIT_ENABLED, off by default: with it off an accepted payment waits in Zahlex until someone submits it there). When Zahlex cannot send — clearing window closed, rail service halted, instant-payment liquidity short — no status message is sent; Zahlex retries for about eight and a half hours and then hands the payment to its operator with the reason recorded. Review outgoing wires pending for longer than a working day: read them with payment-status, and ask Zahlex operations about the ones still accepted.

Recalls. A recall of a payment is a request the counterparty may refuse. The recall messages themselves are acknowledged and logged only; the outcome arrives as an ordinary status (rejected, cancelled) or as a return, and only those change anything. A return after settlement is a new inbound payment, parked for an operator — see List inbound payments.

Product version note (September 2026). OWT 2.0.10 replaces 2.0.9. What changes for operations: an outgoing wire now spends time in pending between screening and settlement instead of completing at once, and it completes only on the rail's settled; a refused payment is reversed automatically. The payment-status and payment-pending events may appear repeatedly in a transfer's event list — each is one status message from the rail. Bookkeeping entries are unchanged. A product version from before 2.0.10 calls the step with type: "mock" (or no type) and has no payment-status event: in demo mode it keeps behaving exactly as before, but under the real driver such a call is held with the reason legacy_call_type and nothing reaches the rail — a payment the rail accepted could never be followed by that product. Migrate the product or resolve the held transfer by hand. Related: Products, KYT.

DSL Context Variables

When DSL rules execute, the following variables are available:

Identity & Status Fields

VariableTypeDescription
@eventobjectThe Transfer object being processed
@event.transfer_idstringTransfer UUID
@event.product_idstringProduct UUID
@event.tenant_idstringTenant UUID
@event.typestringTransfer type (sepa, wire, crypto_withdrawal, etc.)
@event.transfer_typestringTransfer type enum
@event.statusstringCurrent status
@event.created_atstringISO timestamp of creation
@event.channel_idstringChannel ID

Customer & Account Fields

VariableTypeDescription
@event.customer_idstring⚠️ Deprecated: Use $context.ori.customer.id
@event.ori_customer_idstringOriginator customer UUID (prefer $context.ori.customer.id)
@event.ori_customer_namestringOriginator customer name (prefer $context.ori.customer.name)
@event.ben_customer_idstringBeneficiary customer UUID (prefer $context.ben.customer.id)
@event.ben_customer_namestringBeneficiary customer name (prefer $context.ben.customer.name)
@event.ori_account_idstringOriginator account UUID (prefer $context.ori.account.id)
@event.ben_account_idstringBeneficiary account UUID (prefer $context.ben.account.id)
@event.ori_ibanstringOriginator IBAN
@event.ori_bicstringOriginator BIC/SWIFT
@event.ben_ibanstringBeneficiary IBAN
@event.ben_bicstringBeneficiary BIC/SWIFT

Note: For enriched party data (customer type, status, account balance, currency), use $context.ori.* and $context.ben.* instead of @event.* fields. See DSL Context section.

Amount Semantic Model

┌─────────────────────────────────────────────────────────────────────┐
│                         TRANSFER                                     │
├─────────────────────────────────────────────────────────────────────┤
│  DEBIT SIDE (from originator)    │  CREDIT SIDE (to beneficiary)    │
│  ─────────────────────────────   │  ─────────────────────────────   │
│  txn_amt     = debit amount      │  ben_amt     = credit amount     │
│  txn_ccy     = debit currency    │  ben_ccy     = credit currency   │
│  txn_feeAmt  = fee (debit ccy)   │                                  │
│  txn_netAmt  = txn_amt - fee     │                                  │
├─────────────────────────────────────────────────────────────────────┤
│  FX_RATE = ben_amt / txn_netAmt  (when txn_ccy ≠ ben_ccy)           │
│  Same currency: txn_ccy = ben_ccy, txn_amt = ben_amt (no FX)        │
└─────────────────────────────────────────────────────────────────────┘

Amount Fields — MAJOR UNITS (for display/notifications)

VariableTypeUnitsDescriptionExample (126 USDC)
@event.amountstringMAJORTransaction amount (legacy alias)"126"
@event.txn_amtfloat64MAJORDebit amount for arithmetic126.0
@event.ori_amountstringMAJOROriginator amount"126"
@event.ben_amtstringMAJORCredit amount to beneficiary"92.00"
@event.txn_netAmtstringMAJORNet debit after fees"125"
@event.txn_feeAmtstringMAJORFee in debit currency"1"
@event.txn_spreadAmtstringMAJORFX spread amount"0.50"
@event.bc_feeAmountstringMAJORBlockchain gas fee"0.001"
@event.txn_transferGasFeestringMAJORTransfer gas fee"0.0005"
@event.txn_sepaFeestringMAJORSEPA fee"0.25"

Amount Fields — MINOR UNITS (for calculations/ledger)

VariableTypeUnitsDescriptionExample (126 USDC)
@event.txn_amt_minorint64MINORDebit amount126000000
@event.ben_amt_minorint64MINORCredit amount to beneficiary9200 (EUR cents)
@event.txn_netAmt_minorint64MINORNet debit after fees125000000
@event.txn_feeAmt_minorint64MINORFee in debit currency1000000

Currency Fields

VariableTypeDescription
@event.currencystringTransaction currency (legacy alias)
@event.txn_ccystringDebit currency code (e.g., "USDC")
@event.ori_currencystringOriginator currency
@event.ben_ccystringCredit currency code (e.g., "EUR")

FX & Rate Fields

VariableTypeUnitsDescription
@event.fx_ratestringRATIOFX rate (decimal string, e.g., "0.9174")

Reference Fields

VariableTypeDescription
@event.txn_instructionIdstringInstruction ID for idempotency
@event.txn_externalIdstringExternal reference ID
@event.bc_txHashstringBlockchain transaction hash
@event.tokenstringToken symbol (USDC, ETH, BTC)
@event.descriptionstringTransfer description
@event.txn_paymentPurposestringPayment purpose

KYT/Compliance Fields

VariableTypeDescription
@event.kyt_statusstringKYT validation status
@event.kyt_risk_scoreanyKYT risk score
@event.scr_riskLevelstringScreening risk level

Metadata

VariableTypeDescription
@event.metadataobjectTransfer metadata JSON

DSL Params ($params.*)

VariableTypeUnitsDescription
$params.event_idstring—Current event type (e.g., "init", "swap")
$params.txn_idstring—Transfer UUID
$params.customer_idstring—Customer UUID
$params.system_ratefloat64RATIOSystem FX rate (auto-fetched)
$params.fx_ratefloat64RATIOManual FX rate (from question answer)
$params.answerobject—Question form answer data

Note: Any field stored via assign in previous events is persisted to Transfer.Context and merged into $params for subsequent events.


DSL Context ($context.*)

The context provides enriched party data (customer + account) for both originator and beneficiary.

Originator Context ($context.ori.*)

VariableTypeUnitsDescription
$context.ori.customer.idUUID—Customer UUID
$context.ori.customer.namestring—Customer name
$context.ori.customer.typestring—Customer type
$context.ori.customer.statusstring—Customer status
$context.ori.customer.metadataobject—Customer metadata
$context.ori.account.idUUID—Account UUID
$context.ori.account.currencystring—Account currency (e.g., "EUR")
$context.ori.account.balanceint64MINORAvailable balance in minor units
$context.ori.account.balance_fmtstring—Formatted balance (e.g., "15.00 EUR")
$context.ori.account.ledgerstring—Subledger code (e.g., "20212-88b0a9d3")

Beneficiary Context ($context.ben.*)

VariableTypeUnitsDescription
$context.ben.customer.idUUID—Customer UUID
$context.ben.customer.namestring—Customer name
$context.ben.customer.typestring—Customer type
$context.ben.customer.statusstring—Customer status
$context.ben.customer.metadataobject—Customer metadata
$context.ben.account.idUUID—Account UUID
$context.ben.account.currencystring—Account currency
$context.ben.account.balanceint64MINORAvailable balance in minor units
$context.ben.account.balance_fmtstring—Formatted balance
$context.ben.account.ledgerstring—Subledger code

Transfer-level Context

VariableTypeUnitsDescription
$context.precision_factorint64—10^(txn_decimals - ori_decimals) for currency conversion
$context.txn_ccy_decimalsint—Debit currency decimals (e.g., 6 for USDC)
$context.ori_ccy_decimalsint—Account currency decimals (e.g., 2 for EUR)

Fee Context

VariableTypeUnitsDescription
$context.fee_modestring—Who bears the fee: OUR, BEN or SHA. A locked quote decides, because its figures are the ones booked — and a quote locked without a bearer answers BEN, the bearer its arithmetic is. Empty only for a transfer with no locked quote whose finalize payload names none, which includes every v1 transfer: fee_mode on POST /v1/transfers is not persisted
$context.fee_amountint64MINORFee amount in debit currency
$context.fee_currencystring—Fee currency code
@event.ori_fee_minor / @event.ori_feeint64 / stringMINOR / majorThe part of the fee the originator pays on top of the amount, from the locked quote. Present only when the transfer carries a quote priced with a bearer
@event.ben_fee_minor / @event.ben_feeint64 / stringMINOR / majorThe part of the fee taken out of what the beneficiary receives, from the locked quote. Present only with a quoted bearer; ori_fee_minor + ben_fee_minor = txn_feeAmt_minor
@event.fee_bearerstring—Same value and same rule as $context.fee_mode: the locked quote decides, a quote locked without a bearer publishes BEN, and the key is absent only when the transfer carries no locked quote and its payload names none. The tariff action reads it from here, so a product pricing its own fee is priced for the bearer the booked figures were priced for — not necessarily the one the request stated. try(@event.fee_bearer, "OUR") therefore falls back only on a bearer-less transfer, never on a bearer-less quote

Fee Modes (ISO 20022):

ModeDescriptionWho Pays
OURSender pays all feesOriginator, on top of the amount; the beneficiary receives the full amount
BENBeneficiary pays all feesDeducted from the amount before conversion
SHAShared feesSplit by the matched fee range's sender_share_percent; a range that declares none refuses the payment (tariffs.sha_share_not_declared)

A locked quote decides the bearer both fields report, because the figures the transfer books came from it. A quote locked WITHOUT a bearer was priced as if the beneficiary bore the fee — the whole fee out of the amount — so it answers BEN whatever the finalize request stated, and a product pricing its own fee books what the customer was quoted instead of charging it a second time on top. A quote locked WITH a bearer names the same one the transfer does; a transfer consuming it under another is refused.

A product reads the mode as $context.fee_mode when it has to branch on it, and gets the split either from the event (when a locked quote priced the transfer) or from its own tariff action, which is asked for the bearer automatically: the executor publishes @event.fee_bearer and the action merges the event payload under its own block, so no fee_bearer field is written in the DSL — the grammar has none and writing one stops the product loading. Prefer the event's split so the booking matches the quote to the minor unit:

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))

Note: $context.ori.* and $context.ben.* are populated when the respective party has an account in the system. For external parties, these may be empty.


Important: When to Use Major vs Minor Units

Use CaseUnitsField Example
Display to userMAJOR@event.txn_amt → "126"
Notifications/emailsMAJOR@event.txn_amt → "126 USDC"
Ledger GL entriesMINOR@event.txn_amt_minor → 126000000
Arithmetic calculationsMINOR@event.txn_amt_minor * rate
Balance checksMINORCompare with $context.ori.account.balance
Precision conversion—Divide by $context.precision_factor

Legacy aliases: @event.amount and @event.currency are aliases for @event.txn_amt and @event.txn_ccy

Example DSL Rule

when evaluate $params.event_id equals "init" then sequence {
  # Validate account has sufficient balance (using context)
  # $context.ori.account.balance is in MINOR units
  when evaluate $context.ori.account.balance gte @event.txn_amt_minor then sequence {
    
    # Get exchange rate via tariff service
    tariff type = "exchange" {
      ori_amt = $context.ori.account.balance
      ori_ccy = $context.ori.account.currency
      ben_currency = @event.txn_ccy
      output = exchange_result
    }
    
    # Store rate for swap event
    assign system_rate = $state.exchange_result.rate
    
    # Book initial GL entries using MINOR units
    gl-batch txn = $params.txn_id event = "init" {
      book ledger = $context.ori.account.ledger op = debit amount = @event.txn_amt_minor desc = "Debit customer account"
      book ledger = "4022" op = credit amount = @event.txn_amt_minor desc = "Suspense account"
    }
    
    # Send notification using MAJOR units for display
    notify {
      type: email
      template: "transfer_initiated"
      to: "@event.ori_customer_email"
      data: {
        "amount": "{@event.txn_amt}",
        "currency": "{@event.txn_ccy}",
        "balance_after": "{$context.ori.account.balance_fmt}"
      }
    }
  }
}

when evaluate $params.event_id equals "swap" then sequence {
  # Use precision_factor from context (calculated based on currencies)
  # EUR(2)→EUR(2): factor=1, USDC(6)→EUR(2): factor=10000
  assign book_amt_minor = @event.txn_amt_minor * $state.system_rate / $context.precision_factor
  
  gl-batch txn = $params.txn_id event = "swap" {
    book ledger = "101211" op = debit amount = $state.book_amt_minor desc = "Liquidity provider"
    book ledger = $context.ori.account.ledger op = credit amount = @event.txn_amt_minor desc = "Customer account"
  }
}

Error Handling

Error Response Format

{
  "status": 400,
  "message": "Failed to create transfer"
}

Common Error Codes

CodeStatusRaised when
transfers_m.product_id_or_code_required400Neither product_id nor product_code was sent
transfers_m.invalid_amount_format400Amount is not an integer in minor units
transfers_m.invalid_currency400Currency code is unknown or inactive
transfers_m.fx_requires_quote400Cross-currency finalize without quote_id
transfers_m.only_draft_can_be_updated400PUT/PATCH on a transfer past draft
transfers_m.only_draft_can_be_deleted400DELETE on a transfer past draft
transfers_m.cannot_cancel_terminal_transfer400Cancel on a terminal transfer
transfers_m.cannot_execute_event_terminal_transfer400Execute on a terminal transfer
transfers_m.cannot_execute_same_event_consecutive400Repeat of an event the product does not repeat
transfers_m.idempotency_key_conflict409Same key, different request body
transfers_m.quote_idempotency_conflict409Same quote key, different quote request
transfers_m.draft_finalize_in_progress409Edit, cancel or delete while a finalize holds the draft
transfers_m.preflight_denied422The product matrix refused the finalized context
transfers_m.quote_cannot_be_priced503No FX source, no rate for the pair, or unconfigured storage
transfers_m.scheduled_worker_not_implemented501Either /transfers/scheduled route
dsl_m.execution_failed500A DSL rule run failed and raised no AppError of its own

Note the last one is in the DSL catalogue, not the transfers one.

A failing rule run answers 500 with dsl_m.execution_failed, and the message is the underlying error itself — a journal a product cannot balance reads "Journal entries are not balanced in CHF. Debit: 13000, Credit: 3000". The status is deliberately not derived from the inner failure: a product whose bookings do not balance, or that names a ledger the tenant does not have, is a configuration defect on our side, not a bad request from the caller.

Query Parameters

The transfers API uses a JSON-based query parameter system for filtering and sorting, similar to the items API.

Search Parameters

The API uses dot notation for search parameters (e.g., search.status, search.product_code).

Format: search.{field}={value}

Examples:

  • search.status=pending - Filter by status
  • search.product_code=CRD - Filter by product code
  • search.txn_ccy=EUR - Filter by currency
  • search.ori_customer_id=123e4567-e89b-12d3-a456-426614174050 - Filter by originator customer ID
  • search.ben_customer_id=123e4567-e89b-12d3-a456-426614174050 - Filter by beneficiary customer ID
  • search.customer_id=123e4567-e89b-12d3-a456-426614174050 - Deprecated: Use search.ori_customer_id. Filter by originator customer ID

Supported Fields:

  • status - Transfer status
  • type - Transfer type
  • ori_customer_id - Originator customer UUID (XZiel: ori_*)
  • ben_customer_id - Beneficiary customer UUID (XZiel: ben_*) - for internal transfers
  • customer_id - Deprecated: Use ori_customer_id. Originator customer UUID
  • product_id - Product UUID
  • product_code - Product code (alternative to product_id)
  • txn_ccy - Currency code
  • created_at - Creation timestamp
  • updated_at - Last update timestamp

Note: product_code can be used as an alternative to product_id. If the product code is not found, the query returns no results.

Sort Parameters

The sort parameter uses a simple format with optional - prefix for descending order.

Format: sort={field} or sort=-{field} (prefix with - for descending)

Examples:

  • sort=-created_at - Sort by creation date, newest first
  • sort=created_at - Sort by creation date, oldest first
  • sort=-txn_amt - Sort by amount, highest first

Supported Fields: Same as search fields, plus txn_amt (amount)

Direction: Prefix with - for descending, omit for ascending

Pagination

  • limit: Number of items per page (1-100, default: 20)
  • offset: Starting position (default: 0)

Example Query

GET /v1/transfers?limit=20&offset=0&search.status=pending&search.product_code=CRD&search.txn_ccy=EUR&sort=-created_at

Authentication

All endpoints require JWT authentication via Bearer token:

Authorization: Bearer 
Accept-Language: en

The ori_customer_id (originator customer) and user_id are extracted from the JWT token context. For internal transfers, ben_customer_id is resolved from the beneficiary account.

The Accept-Language header can be used to specify the preferred language for responses (default: en).

Field Reference

Required Fields by Transfer Type

Transfer TypeRequired FieldsOptional but Common
CRD (Crypto Deposit)product_code, txn_amt, txn_ccy, ben_walletAddress, bc_network, token, bc_txHashori_walletAddress, txn_externalId
CRW (Crypto Withdrawal)product_code, txn_amt, txn_ccy, ori_account_id, (ben_walletAddress OR ben_iban)bc_network, token, txn_feeAmt, ben_amt, ben_ccy
OWN (Own Accounts)product_code, txn_amt, txn_ccy, ori_account_id, ben_account_idtxn_paymentPurpose
INT (Internal)product_code, txn_amt, txn_ccy, ori_account_id, ben_account_idtxn_paymentPurpose, txn_instructionId
IWT (Incoming Wire)product_code, txn_amt, txn_ccy, ori_iban, ben_account_id OR ben_ibanori_name, ori_bic, ben_name, ben_bic
OWT (Outgoing Wire)product_code, txn_amt, txn_ccy, ori_account_id, ben_ibanori_name, ori_iban, ori_bic, ben_name, ben_bic, txn_feeAmt, ben_amt, ben_ccy

Common Fields

Identity & Product Fields

FieldTypeDescription
product_codestringProduct code (CRD, CRW, OWN, INT, IWT, OWT)
product_idUUIDAlternative to product_code
txn_paymentPurposestringTransfer description
txn_externalIdstringExternal reference ID
txn_instructionIdstringInstruction ID for idempotency
metadataobjectAdditional metadata

Amount Fields (API Request/Response — MINOR UNITS)

FieldTypeUnitsDescriptionExample
txn_amtintegerMINORDebit amount (from originator)1500 (€15.00) or 126000000 (126 USDC)
txn_netAmtintegerMINORNet debit after fees (input to FX)1450 (€14.50)
txn_feeAmtintegerMINORFee in debit currency50 (€0.50)
ben_amtintegerMINORCredit amount (to beneficiary)9200 (€92.00)

Currency Fields

FieldTypeDescription
txn_ccystringDebit currency code (ISO 4217 or crypto)
ben_ccystringCredit currency code

Fee Mode Field

FieldTypeDescription
fee_modestringFee mode: OUR, BEN, SHA

Fee Mode Calculation — one formula with a share: ori_fee + ben_fee = fee, the sender is debited txn_amt + ori_fee, and txn_netAmt = txn_amt − ben_fee is what converts:

ModeSender is debitedtxn_netAmt (converts)ben_amt
OURtxn_amt + feetxn_amtconvert(txn_amt)
BENtxn_amttxn_amt - feeconvert(txn_amt - fee)
SHAtxn_amt + ori_feetxn_amt - ben_feeconvert(txn_amt - ben_fee), with ori_fee = round_half_up(fee × sender_share_percent / 100) from the matched fee range

Account Fields

FieldTypeDescription
ori_account_idUUIDOriginator account (required for outbound)
ben_account_idUUIDBeneficiary account (for internal transfers)
ori_walletAddressstringOriginator wallet address (crypto)
ben_walletAddressstringBeneficiary wallet address (crypto)
ori_ibanstringOriginator IBAN (fiat transfers)
ben_ibanstringBeneficiary IBAN (fiat transfers)
ori_bicstringOriginator BIC/SWIFT code
ben_bicstringBeneficiary BIC/SWIFT code
ori_namestringOriginator name
ben_namestringBeneficiary name

Screening Fields

Populated for KYT screening rules. A country is only present when it is on record — it is never derived from an account number, because an account's country is not the party's country. Two different code systems are in play on purpose:

FieldTypeDescription
ori_ctrystringOriginator country, ISO 3166-1 alpha-3 (e.g. CHE), from the party's address. Omitted when the payer is external or has no country on record.
ben_ctrystringBeneficiary country, ISO 3166-1 alpha-3, from the beneficiary's own record — a customer or a saved recipient, never the recipient's owning customer. Omitted when unknown.
ben_bankCtrystringBeneficiary bank country, ISO 3166-1 alpha-2 (e.g. CH), read from characters 5-6 of ben_bic per ISO 9362. Alpha-2 because that is what a BIC carries.
txn_referenceTextstringPayment reference text, same source as txn_paymentPurpose. Omitted when the transfer has no description.
kyt_party_country_missingstringComma-separated list (originator, beneficiary) of parties that are records of ours but whose country could not be established. Set when a country lookup finds nothing AND when the beneficiary classification itself fails — not knowing whether the beneficiary is a record of ours is the same gap as not knowing its country. Screening holds the payment when this is present.
ben_party_idstringThe beneficiary's own record id, present only when the beneficiary is a record of ours.
ben_party_kindstringcustomer or recipient, saying which table ben_party_id names. Screening forwards a beneficiary customer id only for customer, because ben_customer_id holds the saved payee's OWNING customer — our own payer — and would otherwise be sent as the beneficiary's own id. Both keys are absent when the beneficiary is external or could not be classified.

These fields are executor-owned: a caller payload cannot assert the countries, the amount or currency (including txn_baseAmt, which is deleted when the canonical model leaves a literal zero so an amount-limit rule cannot compare against nothing), the party identity (ori_name, ben_name, ori_iban, ben_iban, ori_bic, ben_bic, ori_customer_id, ben_customer_id, customer_id, ben_party_id, ben_party_kind), whose lists are screened and in which direction (tenant_id, org_id, organization_id, tenant, direction), which payment and when (transfer_id, txn_id, transaction_id, external_ref_id, txn_externalId, meta_hashId, tx_timestamp, txn_txTimestamp, txn_timestamp, transaction_timestamp, created_at), what the payment says it is (txn_referenceText, txn_paymentPurpose, txn_txType, kyt_type, chn_channelType), or a previous verdict (kyt_status, kyt_risk_score, scr_riskLevel).

Writing them last in buildEvent is not enough on its own — screening is called with payload_type: "original", which merges event ⊕ params with params winning, and the caller's HTTP body reaches BOTH halves. The filter therefore runs on all three doors: mergePayloadIntoEvent, mergePayloadToParams, and the flatten of transfer.Metadata, which is caller input persisted at create time and fills any key the executor left unset — so a field written only under a condition was assertable there even while both merges refused it. The payment rail's facts (payment_status, payment_id, payment_sequence, reason_code) are in the same set: the webhook consumer states them on the run's context, which no request body or metadata reaches. rate_source, event_id and dry_run stay caller-supplied.

The consumer reads each fact through a list of alternative keys and takes the first NON-EMPTY one, so a key is only protected when everything ahead of it in that list is protected too; pins the two lists together across the module boundary.

ori_ctry, ben_ctry and kyt_party_country_missing are the exception on the event door AND on the metadata flatten: addPartyCountryFields runs after both and is authoritative for parties on record, while an external party's country can only come from the caller. They are still refused on the params door, which nothing re-derives.

txn_baseAmt is no longer emitted. It was never populated, and a literal 0 satisfied an "is the field present" check, so amount-limit rules compared against nothing. A rule that needs a base amount must not rely on this field.

Blockchain Fields

FieldTypeDescription
bc_networkstringBlockchain network (ERC20, TRC20, BTC, SOL)
tokenstringToken symbol (USDC, ETH, BTC)
bc_txHashstringOn-chain transaction hash

Units Summary

ContextUnitsExample FieldExample Value
API RequestMINORtxn_amt1500 (€15.00 EUR)
API ResponseMINORtxn_amt1500 (€15.00 EUR)
DatabaseMINORamount (BIGINT)1500
DSL @event (display)MAJOR@event.txn_amt15.0 or "15"
DSL @event (calc)MINOR@event.txn_amt_minor1500
Ledger entriesMINORbook amount =@event.txn_amt_minor
FX ratesRATIO@event.fx_rate0.9174 (EUR/USDC)
BalancesMINORBalanceAvailable150000 (€1500.00)

Available Balance Management

Balance Fields — All in MINOR UNITS

All balance fields in the system are stored and returned in minor units (integers):

FieldTypeUnitsDescriptionExample
BalanceCurrentint64MINORLedger balance (all posted transactions)150000 (€1500.00)
BalanceAvailableint64MINORAvailable balance (usable by customer)145000 (€1450.00)
BalancePostedint64MINORPosted balance150000 (€1500.00)
BalancePendingint64MINORPending transactions5000 (€50.00)

Banking Best Practices

In banking systems, there are two types of balances:

  1. Ledger Balance (BalanceCurrent): The total balance of all posted transactions in minor units. This is updated immediately when GL entries are created for accounting accuracy.

  2. Available Balance (BalanceAvailable): The balance that customers can actually use in minor units. This excludes:

    • Funds held in suspense accounts (pending KYT/compliance checks)
    • Pending transactions not yet cleared
    • Reserve requirements
    • Regulatory holds

Current Implementation

The system currently updates both BalanceCurrent and BalanceAvailable simultaneously when GL entries are created (see CascadeLedgerBalanceUpdate in service implementation):

ledger.BalanceCurrent += delta
ledger.BalanceAvailable += delta  // Currently updated same as BalanceCurrent

Note: There's a comment in updateLedgerBalance (line 965) indicating this is simplified: // Simplified; in reality, might be different

Best Practice for KYT-Cleared Transactions

For crypto deposits (CRD) and other transactions requiring KYT validation:

Current Flow (CRD Example)

  1. init event: Calculates fees, routes to before-kyt

  2. before-kyt event:

    • Books GL entries:
      • Debit: Suspense Crypto (4022)
      • Credit: Customer Crypto Deposits (20212)
    • Calls KYT service
    • Issue: Both ledger and available balances are updated, but funds should NOT be available yet
  3. kyt-green event (KYT cleared):

    • Books GL entries:
      • Debit: Safeguard wallets (102121)
      • Credit: Suspense Crypto (4022)
    • Issue: Available balance should be updated here, but currently it's updated in before-kyt too

Option 1: Event-Based Available Balance Updates (Recommended)

Update BalanceAvailable only when funds are actually available to the customer:

  • before-kyt: Update BalanceCurrent only (funds in suspense, not available)
  • kyt-green: Update both BalanceCurrent AND BalanceAvailable (funds cleared, now available)

Implementation:

  1. Modify CascadeLedgerBalanceUpdate to accept an updateAvailable parameter
  2. In GL batch action handler, check the event type:
    • If event is before-kyt or similar (suspense): updateAvailable = false
    • If event is kyt-green or similar (cleared): updateAvailable = true
  3. Update available balance only when updateAvailable = true

Option 2: Ledger Type-Based Logic

Use ledger codes/types to determine if entries should update available balance:

  • Suspense accounts (e.g., "4022"): Don't update available balance
  • Customer accounts (e.g., "20212", "102121"): Update available balance when credited

Option 3: DSL-Controlled Available Balance

Add a DSL action or parameter to explicitly control available balance updates:

gl-batch txn = $params.txn_id event = "kyt-green" update_available = true {
  book ledger = "102121" op = debit amount = "@event.txn_amt" ...
  book ledger = "4022" op = credit amount = "@event.txn_amt" ...
}

Account Balance Display

Customer accounts are linked to ledgers via Account.LedgerID. When displaying balance to clients:

  1. For API responses: Use Ledger.BalanceAvailable (not BalanceCurrent)
  2. For account balance queries: Query the ledger's BalanceAvailable field
  3. For balance history: The balance_history table tracks balance_available separately

Example: CRD Flow with Proper Available Balance

Before KYT (before-kyt event):

  • Ledger 4022 (Suspense): BalanceCurrent += amount, BalanceAvailable += amount
  • Ledger 20212 (Customer Deposits): BalanceCurrent += amount, BalanceAvailable += amount ❌ Should NOT update available yet

After KYT Cleared (kyt-green event):

  • Ledger 102121 (Safeguard wallets): BalanceCurrent += amount, BalanceAvailable += amount ✅ Now available
  • Ledger 4022 (Suspense): BalanceCurrent -= amount, BalanceAvailable -= amount

Result: Customer sees balance increase only after kyt-green, not during before-kyt.

Implementation Notes

The current code in service implementation updates both balances:

ledger.BalanceCurrent += delta
ledger.BalanceAvailable += delta  // Should be conditional based on event/ledger type

Recommended fix: Add logic to determine if available balance should be updated based on:

  • Event type (e.g., kyt-green = update available, before-kyt = don't update)
  • Ledger type/code (suspense accounts don't affect available balance)
  • Transaction status (pending vs cleared)

Permissions

RolePermissions
AdministratorCRUDA (Create, Read, Update, Delete, Admin)
UserCRUD (Create, Read, Update, Delete)

PATCHUpdate Transaction

Previous Page

POSTExecute Transfer Event

Executes one DSL event on a transfer. ANSWERS 200, NOT 201, and the body is {transfer, result, question?} — the transfer in the minor-units shape, the raw DSL result, and a question member only for a QUESTION outcome. THE REQUEST BODY IS A FREE-FORM JSON OBJECT. The handler decodes it into map[string]any and passes it through; a structured body with dry_run, force, reason or a nested payload wrapper is read by nothing. Payment rail facts supplied by the caller are ignored as well: they come from the rail. 400 covers an unparseable transfer_id, a missing event_type, an undecodable body, a terminal transfer (transfers_m.cannot_execute_event_terminal_transfer), a repeat of an event the product does not allow to repeat (transfers_m.cannot_execute_same_event_consecutive) and a row that is not a payment — a beneficiary's row, the receiving company's record of an on-us credit linked to the payment through parent_id, is refused with transfers_m.cannot_execute_event_on_beneficiary_row, whose details name the payment the event belongs to under the transfer_id field. THE LIST OF STATUSES BELOW IS NOT CLOSED. runTransferEvent propagates a DSL action's own *errs.AppError unchanged, so an error raised inside a call{} interface or a gl-batch surfaces with whatever status it carried. The three that a gl-batch reaches through the ledgers module are declared below: 402 ledgers.insufficient_funds against an underfunded ledger, 409 ledgers.ledger_inactive / ledgers.ledger_closed from EnsureLedgerPostable on the journal path, and 422 from the transit-ledger currency guards and from the tariff service a tariff{} step calls. A product that calls another interface can surface a status none of them lists. Only a non-AppError failure is flattened to 500 (dsl_m.execution_failed — the DSL catalogue, not the transfers one). An unknown transfer_id never answers 404. The RBAC check runs first, so it is normally a 403; a caller who passes that check reaches loadTransferForEvent, which wraps the storage miss in errs.MsgDatabaseError with no code and so answers 500.

On this page

Purpose and useOverviewCore ConceptsDSL-Driven WorkflowsTransfer TypesTransfer DirectionDirection TypesDirection Determination LogicAccount ValidationImportant NotesWhen a payee cannot be paidBeneficiary legsExamples by ScenarioTransfer StatusesStatus TransitionsAmount Representation (Minor Units)Currency Precision ReferenceExamplesCommon MistakesInternal ArchitectureDSL Event Amount FieldsDSL Event Ledger-Routing FieldsEndpointsCreate TransferFinalize transfer (after pre-flight discovery)Create draft transfer (modern v2 flow)Edit draft transfer (modern v2 flow)List transfers (modern v2 flow)Get transfer by ID (modern v2 flow)Server-owned metadata keysFinalize draft transfer (modern v2 flow)Schedule transferTransfer QuotesCreate Transfer QuoteGet Transfer QuoteConsume Quote in Create TransferPayload Examples by Transfer TypeCRD - Crypto DepositCRW - Crypto WithdrawalOWN - Internal Transfer Between Own AccountsINT - Internal Transfer (Within Core Banking System)IWT - Incoming Wire TransferOWT - Outgoing Wire TransferList TransfersQuery ParametersExample RequestGet Transfer by IDUpdate TransferCancel TransferDelete TransferGet Status HistoryList Transfer Status ChangesQuery ParametersExample RequestList inbound paymentsList Available EventsQuery ParametersExample RequestList Transfer EventsGet Event DetailsExecute EventSet Manual FX RateSign TransferNot-implemented endpointsEvery v1 transfer response is the minor-units shapeTransfer-scoped events are not the get_all envelopeExecuting an event answers 200, and its body is free-formAn unknown transfer_id is never a 404 on the five v1 write routesKYT WebhookDSL IntegrationEvent TypesPayment rail gateway (Zahlex)DSL Context VariablesIdentity & Status FieldsCustomer & Account FieldsAmount Semantic ModelAmount Fields — MAJOR UNITS (for display/notifications)Amount Fields — MINOR UNITS (for calculations/ledger)Currency FieldsFX & Rate FieldsReference FieldsKYT/Compliance FieldsMetadataDSL Params ($params.*)DSL Context ($context.*)Originator Context ($context.ori.*)Beneficiary Context ($context.ben.*)Transfer-level ContextFee ContextExample DSL RuleError HandlingError Response FormatCommon Error CodesQuery ParametersSearch ParametersSort ParametersPaginationExample QueryAuthenticationField ReferenceRequired Fields by Transfer TypeCommon FieldsIdentity & Product FieldsAmount Fields (API Request/Response — MINOR UNITS)Currency FieldsFee Mode FieldAccount FieldsScreening FieldsBlockchain FieldsUnits SummaryAvailable Balance ManagementBalance Fields — All in MINOR UNITSBanking Best PracticesCurrent ImplementationBest Practice for KYT-Cleared TransactionsCurrent Flow (CRD Example)Recommended ApproachAccount Balance DisplayExample: CRD Flow with Proper Available BalanceImplementation NotesPermissions