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:
- The system loads DSL rules from
Product.Settings.dsl - The DSL Engine executes the appropriate event rules (e.g.,
init,before_kyt,after_kyt) - DSL actions affect the transfer (book GL entries, send notifications, update status)
- Events are recorded for audit trail
Transfer Types
| Type | Description |
|---|---|
sepa | SEPA transfer within the European payment area |
wire | International wire transfer |
internal | Internal transfer between customers |
iwt | Inward transfer (from external to internal account) |
owt | Outward transfer (from internal to external account) |
crypto_withdrawal | Cryptocurrency withdrawal |
crypto_deposit | Cryptocurrency 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
| Direction | Description | Perspective |
|---|---|---|
outbound | Money leaving the system (customer sending) | Originator is our customer |
inbound | Money entering the system (customer receiving) | Beneficiary is our customer |
internal | Money moving within the system | Both 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_idexists 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_iddoes NOT exist (or is null), butben_account_id/ben_walletAddress/ben_ibanexists - 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_idis 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 explicitben_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
- the payer's row:
- 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:
-
If
ori_account_idis provided:- System checks if the account exists in the database
- If account exists →
outboundorinternal(depending onben_account_id) - If account does NOT exist → Error:
ori_account_id not found(validation fails)
-
If
ben_account_idis 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_idis provided but doesn't exist in the database, the system currently treats it asoutboundbefore 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).
-
The payer's row:
direction = "outbound"ori_account_id= the payer's accountben_account_id= the beneficiary's account hereparent_id=null
-
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.
| Status | Terminal | Description |
|---|---|---|
draft | no | Initial state, transfer being prepared |
pending | no | Ready for processing, or in flight |
scheduled | no | One-shot future-dated payment awaiting its execution date |
recurring | no | Recurring-payment template; produces pending transfers on each cycle |
completed | yes | Successfully completed |
failed | yes | Failed to process |
reversed | yes | Transfer reversed |
cancelled | yes | Cancelled 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 currencyben_amt- Credit amount (to beneficiary, calculated based on FX and fee_mode)ben_ccy- Credit currencytxn_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_feeis charged to the originator on top of the amount,ben_feeis taken out of what the beneficiary receives, and the two add up totxn_feeAmt. Absent otherwise, so a product falls back to its owntariffaction 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'ssender_share_percent)
Currency Precision Reference
| Currency | Decimal Places | Example: 1.5 | Minor Units |
|---|---|---|---|
| USD, EUR, GBP | 2 | $1.50 | 150 |
| JPY, KRW | 0 | ¥150 | 150 |
| BHD, KWD | 3 | 1.500 BHD | 1500 |
| BTC | 8 | 0.00000001 BTC | 1 |
| USDC | 6 | 1.5 USDC | 1500000 |
| ETH | 18 | 1.5 ETH | 1500000000000000000 |
Examples
USD (2 decimal places):
- $1.50 →
150cents - $1000.00 →
100000cents - $0.01 →
1cent
USDC (6 decimal places):
- 1.5 USDC →
1500000micro-USDC - 100.0 USDC →
100000000micro-USDC - 0.000001 USDC →
1micro-USDC
BTC (8 decimal places):
- 0.00000001 BTC →
1satoshi - 1.0 BTC →
100000000satoshis - 0.5 BTC →
50000000satoshis
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
BIGINTcolumns to store amounts in minor units directly (no precision loss) - Go Model: Uses
decimal.Decimalfor 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:
| Field | Format | Example (126 USDC) | Use Case |
|---|---|---|---|
@event.txn_amt | Major units (string) | "126" | Display, notifications |
@event.txn_amt_minor | Minor units (integer) | 126000000 | Calculations, ledger operations |
@event.ben_amt | Major units (string) | "50.5" | Display, notifications |
@event.ben_amt_minor | Minor units (integer) | 50500000 | Calculations |
@event.txn_netAmt | Major units (string) | "125" | Display |
@event.txn_netAmt_minor | Minor units (integer) | 125000000 | Calculations |
@event.txn_feeAmt | Major units (string) | "1" | Display |
@event.txn_feeAmt_minor | Minor units (integer) | 1000000 | Calculations |
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.
| Field | Content | Absent when |
|---|---|---|
@event.ori_account_ledger / @event.ben_account_ledger | The side's current-purpose subledger code | side is external or the account has no current subledger |
@event.ps_out_account_ledger | Pending-settlement (outgoing) subledger code, anchored to the transfer's internal account | no internal anchor or no route for pending_settlement_out |
@event.chi_account_ledger | Compliance-hold (incoming) subledger code | no internal anchor or no route for compliance_hold_in |
@event.cho_account_ledger | Compliance-hold (outgoing) subledger code | no internal anchor or no route for compliance_hold_out |
@event.fees_receivable_ledger | Customer-anchored fee-accrual subledger code | no internal anchor or no route for fees_receivable |
@event.rail | sic-rtgs or sic-ip, from the transfer metadata key rail | metadata absent or names an unknown rail (products default via try(@event.rail, "sic-rtgs")) |
@event.ori_balance_available_minor / @event.ben_balance_available_minor | The 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:
- Customer initiates transfer with
product_id - System automatically resolves
ori_customer_idandben_customer_idfrom:
ori_account_id,ori_wallet_address, orori_ibanfor originatorben_account_id,ben_wallet_address, orben_ibanfor beneficiary
- Transfer is saved with status
draft - DSL is loaded from
Product.Settings.dsl - DSL Engine executes
initevent rules with Transfer as@event - 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: trueErrors (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_withsearch._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
CreatePaymentRequestV2body 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_idreferencecurrencies.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/quotescreates 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/finalizeandPOST /v2/transfers/{transfer_id}/finalizeconsume a quote when top-levelquote_idis present (copied to internal metadata).- Legacy
POST /v1/transfers/finalizeremains supported for existing clients. POST /v1/transfersconsumes a quote only whenmetadata.quote_idis 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_walletAddressis required - system looks up customer by this addressbc_txHashis the on-chain transaction hash for tracking- No
ori_account_idneeded (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 currencyfee_mode: "OUR"= the sender bears the fee, charged on top of the amount, so the originator is debited 100.5 USDCtxn_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 underOUR)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_idandben_account_idare 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_ibanandori_bicidentify the external senderben_account_ididentifies the receiving account- No
ori_account_idneeded (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 currencyfee_mode: "OUR"= the sender bears the fee, charged on top of the amount, so the originator is debited 1,004.50 USDtxn_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 underOUR)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_codecan be used as an alternative toproduct_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
- Common examples:
sort(optional): sort field (prefix with-for descending)- Examples:
sort=-created_at,sort=transfer_id
- Examples:
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=statusorstack=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.
| Field | Meaning |
|---|---|
kind | received — a credit for one of our customers; return — a payment we sent came back |
status | booked — 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 |
reason | why 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_id | the gateway's payment number and the counterparty's own reference |
amount_minor, currency | what arrived, in minor units |
structured_reference, remittance_info | the reference the payer quoted — the only attribution key the gateway exposes |
transfer_id, customer_id, account_id | filled when the payment was booked (or matched before a failure) |
original_payment_id, original_transfer_id | on 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-kytsearch.event_type.nin=status_change(exclude status change rows)search.created_at.gte=2026-01-01 00:00:00 +00:00
- Common examples:
sort(optional): sort field (prefix with-for descending)- Examples:
sort=-created_at,sort=event_type
- Examples:
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_typeorstack=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 (
nameanddescription) are dynamically extracted from the transfer's contextevents:kv-listassignment (defined in DSLinitevent) - Only
initandcompleteevents 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
initevent cannot be rerun; all other events can be rerun by default eventsisnull, not[], when the transfer has no successfully executed non-status_changeevent yet — the handler appends onto a nil slice, and Go marshals that asnull
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:
| Method | Path |
|---|---|
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_netAmtandtxn_feeAmtareCcyAmtWithPrecisionobjects —{amount, currency, precision}— whereamountis 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 insidetxn_amt. - The description is serialised as
txn_paymentPurpose. activeis 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_fundsagainst a ledger whose spendable balance does not cover the posting409 ledgers.ledger_inactive/ledgers.ledger_closedfromEnsureLedgerPostableon the journal path422from the transit-ledger currency guards, and from the tariff service atariff{}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:
429with no token at all. The anonymous branch incrementsrate_limit:ip_global::POST:/v1/kyt/webhookand answers429both past the global IP limit and when the increment itself fails — the ordinary shape of a provider retrying its callbacks from one address.401with a token. The limiter parses theAuthorizationheader itself viacheckAuthorization, ahead ofauth.Middleware. A valid token takes its authenticated branch intofindMatchingEndpoint, which returns"permission denied"WithCode(401)when noapi_permissionrow matches — and a route registered bare has no such row.errs.Newaccepts a plain string as well as aMsgCodeand stores it asAppError.Key, which is whatMachineCode()returns — socodeis not empty, it is the same free-text sentence asmessage. Matchable, but not a dotted key and never translated.500with a token. The same authenticated path answers500 common.server_errorwhen the limiter's own lookups fail;handleRateLimitErrorwrites that one without theAppError, 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:
| Event | Type | Description | Typical Actions |
|---|---|---|---|
init | System | Transfer creation (automatically triggered on transfer creation) | Validate, book initial GL entries, set status, define available events |
complete | System | Transfer completion | Final 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-listassignment in the transfer's context - The
GET /v1/transfers/{transfer_id}/eventsendpoint 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
initandcompletehave 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.eventstable 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 reports | The transfer | Bookkeeping |
|---|---|---|
accepted, submitted, pending, held, querying, expired_unsettled | stays pending | none |
settled | settlement entries, then completed | the OWT settlement batch |
rejected, cancelled, expired | reversed | customer'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 payment | unchanged (completed); the return is parked as an inbound payment | none until an operator decides |
| recall requested / resolved, reconciliation break | unchanged; acknowledged and logged, nothing is written to the transfer | none |
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).
- From Zahlex. The API base URL; a machine API key minted in live mode with the
payments:writeandpayments:readscopes, 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 settingsCOREBANQ_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. - corebanq.listener. Route
/webhooks/zahlexwith the raw body kept (include_raw_body: the signature is computed over the bytes as received), publishing to the RabbitMQ exchangewebhooks. The listener derives the routing key from the path:webhooks.zahlex, with nowebhook.prefix, because the path already starts withwebhook. - CoreBanq configuration (
payment_providerblock):zahlex.base_url,zahlex.api_key(secret),zahlex.webhook_secretequal to the value from step 1 (zahlex.webhook_secret_previousduring a rotation),zahlex.timeoutandzahlex.max_retries, andzahlex.queue.*— the broker (host,port,username,password,virtual_host),exchange: webhooks,queue_name: zahlex.webhooks,routing_key: webhooks.zahlex,listener_route: /webhooks/zahlex,prefetch_countandworker_count. The consumer binds its queue to the exchange under that routing key itself. - Products. The outgoing product must be a version with the
payment-statusstep (OWT 2.0.10 or later; 2.0.9 has no way to receive a rail status), and the incoming productIWTmust be active for step 6.ZLXTis the smoke product for a first payment. - 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 iszahlexat 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 answersacceptedand the transfer stays pending; the rail's messages arrive through the listener and the transfer completes onsettledby itself. If nothing arrives, check in this order: Zahlex's delivery log for our endpoint (4xxthere is the listener refusing,pending, attempts=0is their dispatcher not trying), the exchange binding (rabbitmqctl list_bindingsmust showzahlex.webhooksunderwebhooks.zahlex), and the consumer's counters. A manualpayment-statuson the pending transfer proves the key and the base URL independently of the webhook path. - 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,
83064for 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 aspayment_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 inGET /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
| Variable | Type | Description |
|---|---|---|
@event | object | The Transfer object being processed |
@event.transfer_id | string | Transfer UUID |
@event.product_id | string | Product UUID |
@event.tenant_id | string | Tenant UUID |
@event.type | string | Transfer type (sepa, wire, crypto_withdrawal, etc.) |
@event.transfer_type | string | Transfer type enum |
@event.status | string | Current status |
@event.created_at | string | ISO timestamp of creation |
@event.channel_id | string | Channel ID |
Customer & Account Fields
| Variable | Type | Description |
|---|---|---|
@event.customer_id | string | ⚠️ Deprecated: Use $context.ori.customer.id |
@event.ori_customer_id | string | Originator customer UUID (prefer $context.ori.customer.id) |
@event.ori_customer_name | string | Originator customer name (prefer $context.ori.customer.name) |
@event.ben_customer_id | string | Beneficiary customer UUID (prefer $context.ben.customer.id) |
@event.ben_customer_name | string | Beneficiary customer name (prefer $context.ben.customer.name) |
@event.ori_account_id | string | Originator account UUID (prefer $context.ori.account.id) |
@event.ben_account_id | string | Beneficiary account UUID (prefer $context.ben.account.id) |
@event.ori_iban | string | Originator IBAN |
@event.ori_bic | string | Originator BIC/SWIFT |
@event.ben_iban | string | Beneficiary IBAN |
@event.ben_bic | string | Beneficiary 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)
| Variable | Type | Units | Description | Example (126 USDC) |
|---|---|---|---|---|
@event.amount | string | MAJOR | Transaction amount (legacy alias) | "126" |
@event.txn_amt | float64 | MAJOR | Debit amount for arithmetic | 126.0 |
@event.ori_amount | string | MAJOR | Originator amount | "126" |
@event.ben_amt | string | MAJOR | Credit amount to beneficiary | "92.00" |
@event.txn_netAmt | string | MAJOR | Net debit after fees | "125" |
@event.txn_feeAmt | string | MAJOR | Fee in debit currency | "1" |
@event.txn_spreadAmt | string | MAJOR | FX spread amount | "0.50" |
@event.bc_feeAmount | string | MAJOR | Blockchain gas fee | "0.001" |
@event.txn_transferGasFee | string | MAJOR | Transfer gas fee | "0.0005" |
@event.txn_sepaFee | string | MAJOR | SEPA fee | "0.25" |
Amount Fields — MINOR UNITS (for calculations/ledger)
| Variable | Type | Units | Description | Example (126 USDC) |
|---|---|---|---|---|
@event.txn_amt_minor | int64 | MINOR | Debit amount | 126000000 |
@event.ben_amt_minor | int64 | MINOR | Credit amount to beneficiary | 9200 (EUR cents) |
@event.txn_netAmt_minor | int64 | MINOR | Net debit after fees | 125000000 |
@event.txn_feeAmt_minor | int64 | MINOR | Fee in debit currency | 1000000 |
Currency Fields
| Variable | Type | Description |
|---|---|---|
@event.currency | string | Transaction currency (legacy alias) |
@event.txn_ccy | string | Debit currency code (e.g., "USDC") |
@event.ori_currency | string | Originator currency |
@event.ben_ccy | string | Credit currency code (e.g., "EUR") |
FX & Rate Fields
| Variable | Type | Units | Description |
|---|---|---|---|
@event.fx_rate | string | RATIO | FX rate (decimal string, e.g., "0.9174") |
Reference Fields
| Variable | Type | Description |
|---|---|---|
@event.txn_instructionId | string | Instruction ID for idempotency |
@event.txn_externalId | string | External reference ID |
@event.bc_txHash | string | Blockchain transaction hash |
@event.token | string | Token symbol (USDC, ETH, BTC) |
@event.description | string | Transfer description |
@event.txn_paymentPurpose | string | Payment purpose |
KYT/Compliance Fields
| Variable | Type | Description |
|---|---|---|
@event.kyt_status | string | KYT validation status |
@event.kyt_risk_score | any | KYT risk score |
@event.scr_riskLevel | string | Screening risk level |
Metadata
| Variable | Type | Description |
|---|---|---|
@event.metadata | object | Transfer metadata JSON |
DSL Params ($params.*)
| Variable | Type | Units | Description |
|---|---|---|---|
$params.event_id | string | — | Current event type (e.g., "init", "swap") |
$params.txn_id | string | — | Transfer UUID |
$params.customer_id | string | — | Customer UUID |
$params.system_rate | float64 | RATIO | System FX rate (auto-fetched) |
$params.fx_rate | float64 | RATIO | Manual FX rate (from question answer) |
$params.answer | object | — | 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.*)
| Variable | Type | Units | Description |
|---|---|---|---|
$context.ori.customer.id | UUID | — | Customer UUID |
$context.ori.customer.name | string | — | Customer name |
$context.ori.customer.type | string | — | Customer type |
$context.ori.customer.status | string | — | Customer status |
$context.ori.customer.metadata | object | — | Customer metadata |
$context.ori.account.id | UUID | — | Account UUID |
$context.ori.account.currency | string | — | Account currency (e.g., "EUR") |
$context.ori.account.balance | int64 | MINOR | Available balance in minor units |
$context.ori.account.balance_fmt | string | — | Formatted balance (e.g., "15.00 EUR") |
$context.ori.account.ledger | string | — | Subledger code (e.g., "20212-88b0a9d3") |
Beneficiary Context ($context.ben.*)
| Variable | Type | Units | Description |
|---|---|---|---|
$context.ben.customer.id | UUID | — | Customer UUID |
$context.ben.customer.name | string | — | Customer name |
$context.ben.customer.type | string | — | Customer type |
$context.ben.customer.status | string | — | Customer status |
$context.ben.customer.metadata | object | — | Customer metadata |
$context.ben.account.id | UUID | — | Account UUID |
$context.ben.account.currency | string | — | Account currency |
$context.ben.account.balance | int64 | MINOR | Available balance in minor units |
$context.ben.account.balance_fmt | string | — | Formatted balance |
$context.ben.account.ledger | string | — | Subledger code |
Transfer-level Context
| Variable | Type | Units | Description |
|---|---|---|---|
$context.precision_factor | int64 | — | 10^(txn_decimals - ori_decimals) for currency conversion |
$context.txn_ccy_decimals | int | — | Debit currency decimals (e.g., 6 for USDC) |
$context.ori_ccy_decimals | int | — | Account currency decimals (e.g., 2 for EUR) |
Fee Context
| Variable | Type | Units | Description |
|---|---|---|---|
$context.fee_mode | string | — | 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_amount | int64 | MINOR | Fee amount in debit currency |
$context.fee_currency | string | — | Fee currency code |
@event.ori_fee_minor / @event.ori_fee | int64 / string | MINOR / major | The 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_fee | int64 / string | MINOR / major | The 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_bearer | string | — | 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):
| Mode | Description | Who Pays |
|---|---|---|
OUR | Sender pays all fees | Originator, on top of the amount; the beneficiary receives the full amount |
BEN | Beneficiary pays all fees | Deducted from the amount before conversion |
SHA | Shared fees | Split 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 Case | Units | Field Example |
|---|---|---|
| Display to user | MAJOR | @event.txn_amt → "126" |
| Notifications/emails | MAJOR | @event.txn_amt → "126 USDC" |
| Ledger GL entries | MINOR | @event.txn_amt_minor → 126000000 |
| Arithmetic calculations | MINOR | @event.txn_amt_minor * rate |
| Balance checks | MINOR | Compare 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
| Code | Status | Raised when |
|---|---|---|
transfers_m.product_id_or_code_required | 400 | Neither product_id nor product_code was sent |
transfers_m.invalid_amount_format | 400 | Amount is not an integer in minor units |
transfers_m.invalid_currency | 400 | Currency code is unknown or inactive |
transfers_m.fx_requires_quote | 400 | Cross-currency finalize without quote_id |
transfers_m.only_draft_can_be_updated | 400 | PUT/PATCH on a transfer past draft |
transfers_m.only_draft_can_be_deleted | 400 | DELETE on a transfer past draft |
transfers_m.cannot_cancel_terminal_transfer | 400 | Cancel on a terminal transfer |
transfers_m.cannot_execute_event_terminal_transfer | 400 | Execute on a terminal transfer |
transfers_m.cannot_execute_same_event_consecutive | 400 | Repeat of an event the product does not repeat |
transfers_m.idempotency_key_conflict | 409 | Same key, different request body |
transfers_m.quote_idempotency_conflict | 409 | Same quote key, different quote request |
transfers_m.draft_finalize_in_progress | 409 | Edit, cancel or delete while a finalize holds the draft |
transfers_m.preflight_denied | 422 | The product matrix refused the finalized context |
transfers_m.quote_cannot_be_priced | 503 | No FX source, no rate for the pair, or unconfigured storage |
transfers_m.scheduled_worker_not_implemented | 501 | Either /transfers/scheduled route |
dsl_m.execution_failed | 500 | A 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 statussearch.product_code=CRD- Filter by product codesearch.txn_ccy=EUR- Filter by currencysearch.ori_customer_id=123e4567-e89b-12d3-a456-426614174050- Filter by originator customer IDsearch.ben_customer_id=123e4567-e89b-12d3-a456-426614174050- Filter by beneficiary customer IDsearch.customer_id=123e4567-e89b-12d3-a456-426614174050- Deprecated: Usesearch.ori_customer_id. Filter by originator customer ID
Supported Fields:
status- Transfer statustype- Transfer typeori_customer_id- Originator customer UUID (XZiel: ori_*)ben_customer_id- Beneficiary customer UUID (XZiel: ben_*) - for internal transferscustomer_id- Deprecated: Useori_customer_id. Originator customer UUIDproduct_id- Product UUIDproduct_code- Product code (alternative to product_id)txn_ccy- Currency codecreated_at- Creation timestampupdated_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 firstsort=created_at- Sort by creation date, oldest firstsort=-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_atAuthentication
All endpoints require JWT authentication via Bearer token:
Authorization: Bearer
Accept-Language: enThe 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 Type | Required Fields | Optional but Common |
|---|---|---|
| CRD (Crypto Deposit) | product_code, txn_amt, txn_ccy, ben_walletAddress, bc_network, token, bc_txHash | ori_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_id | txn_paymentPurpose |
| INT (Internal) | product_code, txn_amt, txn_ccy, ori_account_id, ben_account_id | txn_paymentPurpose, txn_instructionId |
| IWT (Incoming Wire) | product_code, txn_amt, txn_ccy, ori_iban, ben_account_id OR ben_iban | ori_name, ori_bic, ben_name, ben_bic |
| OWT (Outgoing Wire) | product_code, txn_amt, txn_ccy, ori_account_id, ben_iban | ori_name, ori_iban, ori_bic, ben_name, ben_bic, txn_feeAmt, ben_amt, ben_ccy |
Common Fields
Identity & Product Fields
| Field | Type | Description |
|---|---|---|
product_code | string | Product code (CRD, CRW, OWN, INT, IWT, OWT) |
product_id | UUID | Alternative to product_code |
txn_paymentPurpose | string | Transfer description |
txn_externalId | string | External reference ID |
txn_instructionId | string | Instruction ID for idempotency |
metadata | object | Additional metadata |
Amount Fields (API Request/Response — MINOR UNITS)
| Field | Type | Units | Description | Example |
|---|---|---|---|---|
txn_amt | integer | MINOR | Debit amount (from originator) | 1500 (€15.00) or 126000000 (126 USDC) |
txn_netAmt | integer | MINOR | Net debit after fees (input to FX) | 1450 (€14.50) |
txn_feeAmt | integer | MINOR | Fee in debit currency | 50 (€0.50) |
ben_amt | integer | MINOR | Credit amount (to beneficiary) | 9200 (€92.00) |
Currency Fields
| Field | Type | Description |
|---|---|---|
txn_ccy | string | Debit currency code (ISO 4217 or crypto) |
ben_ccy | string | Credit currency code |
Fee Mode Field
| Field | Type | Description |
|---|---|---|
fee_mode | string | Fee 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:
| Mode | Sender is debited | txn_netAmt (converts) | ben_amt |
|---|---|---|---|
OUR | txn_amt + fee | txn_amt | convert(txn_amt) |
BEN | txn_amt | txn_amt - fee | convert(txn_amt - fee) |
SHA | txn_amt + ori_fee | txn_amt - ben_fee | convert(txn_amt - ben_fee), with ori_fee = round_half_up(fee × sender_share_percent / 100) from the matched fee range |
Account Fields
| Field | Type | Description |
|---|---|---|
ori_account_id | UUID | Originator account (required for outbound) |
ben_account_id | UUID | Beneficiary account (for internal transfers) |
ori_walletAddress | string | Originator wallet address (crypto) |
ben_walletAddress | string | Beneficiary wallet address (crypto) |
ori_iban | string | Originator IBAN (fiat transfers) |
ben_iban | string | Beneficiary IBAN (fiat transfers) |
ori_bic | string | Originator BIC/SWIFT code |
ben_bic | string | Beneficiary BIC/SWIFT code |
ori_name | string | Originator name |
ben_name | string | Beneficiary 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:
| Field | Type | Description |
|---|---|---|
ori_ctry | string | Originator 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_ctry | string | Beneficiary 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_bankCtry | string | Beneficiary 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_referenceText | string | Payment reference text, same source as txn_paymentPurpose. Omitted when the transfer has no description. |
kyt_party_country_missing | string | Comma-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_id | string | The beneficiary's own record id, present only when the beneficiary is a record of ours. |
ben_party_kind | string | customer 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
| Field | Type | Description |
|---|---|---|
bc_network | string | Blockchain network (ERC20, TRC20, BTC, SOL) |
token | string | Token symbol (USDC, ETH, BTC) |
bc_txHash | string | On-chain transaction hash |
Units Summary
| Context | Units | Example Field | Example Value |
|---|---|---|---|
| API Request | MINOR | txn_amt | 1500 (€15.00 EUR) |
| API Response | MINOR | txn_amt | 1500 (€15.00 EUR) |
| Database | MINOR | amount (BIGINT) | 1500 |
| DSL @event (display) | MAJOR | @event.txn_amt | 15.0 or "15" |
| DSL @event (calc) | MINOR | @event.txn_amt_minor | 1500 |
| Ledger entries | MINOR | book amount = | @event.txn_amt_minor |
| FX rates | RATIO | @event.fx_rate | 0.9174 (EUR/USDC) |
| Balances | MINOR | BalanceAvailable | 150000 (€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):
| Field | Type | Units | Description | Example |
|---|---|---|---|---|
BalanceCurrent | int64 | MINOR | Ledger balance (all posted transactions) | 150000 (€1500.00) |
BalanceAvailable | int64 | MINOR | Available balance (usable by customer) | 145000 (€1450.00) |
BalancePosted | int64 | MINOR | Posted balance | 150000 (€1500.00) |
BalancePending | int64 | MINOR | Pending transactions | 5000 (€50.00) |
Banking Best Practices
In banking systems, there are two types of balances:
-
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.
-
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 BalanceCurrentNote: 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)
-
initevent: Calculates fees, routes tobefore-kyt -
before-kytevent:- 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
- Books GL entries:
-
kyt-greenevent (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-kyttoo
- Books GL entries:
Recommended Approach
Option 1: Event-Based Available Balance Updates (Recommended)
Update BalanceAvailable only when funds are actually available to the customer:
before-kyt: UpdateBalanceCurrentonly (funds in suspense, not available)kyt-green: Update bothBalanceCurrentANDBalanceAvailable(funds cleared, now available)
Implementation:
- Modify
CascadeLedgerBalanceUpdateto accept anupdateAvailableparameter - In GL batch action handler, check the event type:
- If event is
before-kytor similar (suspense):updateAvailable = false - If event is
kyt-greenor similar (cleared):updateAvailable = true
- If event is
- 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:
- For API responses: Use
Ledger.BalanceAvailable(notBalanceCurrent) - For account balance queries: Query the ledger's
BalanceAvailablefield - For balance history: The
balance_historytable tracksbalance_availableseparately
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 typeRecommended 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
| Role | Permissions |
|---|---|
| Administrator | CRUDA (Create, Read, Update, Delete, Admin) |
| User | CRUD (Create, Read, Update, Delete) |
Previous Page
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.