Description
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. Internal Transfer (internal)
- Condition: Both
ori_account_idANDben_account_idexist in the database - Meaning: Both originator and beneficiary are our customers (money moving within the system)
- Direction: Creates TWO legs:
- First leg:
direction = "outbound"(from originator's perspective) - Second leg:
direction = "inbound"(from beneficiary's perspective, created automatically)
- First leg:
- Example: Transfer between two customer accounts within the system
{
"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
Internal Transfer Legs
For internal transfers (type = "internal"), the system automatically creates two transfer records:
-
Primary Leg (Outbound):
direction = "outbound"ori_account_id= Originator's accountben_account_id= Beneficiary's accountparent_id=null
-
Secondary Leg (Inbound):
direction = "inbound"ori_customer_id= Beneficiary's customer ID (swapped)ben_customer_id= Originator's customer ID (swapped)parent_id= Primary leg's transfer ID- Created automatically via
CreateTransferLeg()
Both legs share the same:
amount,currency,type,statusmetadata,context- Linked via
parent_idfor tracking
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
| Status | Description |
|---|---|
draft | Initial state, transfer being prepared |
pending | Ready for processing |
pre_kyt | Before KYT validation (GL: suspense accounts) |
post_kyt | After KYT validation passed |
treasury | Treasury processing stage |
in_transit | Transfer in transit to destination |
reconcile | Awaiting reconciliation |
completed | Successfully completed (terminal) |
failed | Failed to process (terminal) |
reversed | Transfer reversed (terminal) |
cancelled | Cancelled by user (terminal) |
Status Transitions
draft → pending → pre_kyt → post_kyt → treasury → in_transit → reconcile → completed
↓ ↓ ↓ ↓ ↓
failed failed failed failed failed
↓ ↓ ↓ ↓ ↓
reversed reversed reversed reversed reversedAmount 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)fee_mode- Fee policy:OUR(sender pays),BEN(beneficiary pays),SHA(shared)
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 |
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: to create a transfer from a locked quote, pass the quote ID in metadata.quote_id. The backend creates the transfer and marks the quote as consumed in one database transaction. A quote cannot become consumed unless the transfer was created and references that quote.
Response (201 Created):
{
"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",
"txn_amt": 100000,
"txn_ccy": "EUR",
"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 /v1/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 CreatePaymentRequest (not the raw PreFlightRequest JSON from pre-flight). Field names differ on the wire (sender_id vs source_account_id, nested recipient vs flat destination_account_id / destination_counterparty_id, amount.amount_minor vs amount.amount — see products module manual → Client journey for the mapping table). The server maps the body to the same internal matrix filter as pre-flight (PreFlightRequestFromCreatePayment), runs products.ValidatePreFlightRequest, then products.PreFlightForFinalize; on deny it returns 422 with transfers_m.preflight_denied. 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 meta.
See OpenAPI schema → CreatePaymentRequest and PaymentRecipient.
Schedule transfer
POST /v1/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 /v1/transfers/finalize for immediate execution today.
Planned contract (once implemented): same body as finalize plus a schedule object (frequency: never for one-shot future execution, or daily / weekly / biweekly / monthly for recurring templates); persist status = scheduled or status = recurring without running DSL init immediately; a worker executes due rows.
Transfer Quotes
Transfer quotes are short-lived, transfer-owned executable pricing snapshots. They lock the commercial economics that POST /v1/transfers/finalize consumes 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.
Recommended modeling direction: store quote-pricing policy in Product.Settings, for example settings.quote.fx_source. Do not let quote creation fall back to DEFAULT, an empty-source lookup, or “whatever provider currently has a rate”. If no authoritative product pricing policy exists for the quote context, quote creation should fail explicitly rather than silently price against another source.
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 /v1/transfers/finalizeconsumes a quote when top-levelquote_idis present (copied to internal metadata).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.
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
},
"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. When quote fees are charged in the base currency, those fees reduce the convertible principal before the target amount is calculated.
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": 99500000,
"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"= sender pays all feestxn_feeAmt: 500000= 0.5 USDC (fee in debit currency)txn_netAmt: 99500000= 99.5 USDC (net debit = txn_amt - fee)ben_amt: 9200000= 92.0 EUR (credit to beneficiary after FX)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": 99550,
"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"= sender pays all feestxn_feeAmt: 450= 4.50 USD (fee in debit currency)txn_netAmt: 99550= 995.50 USD (net debit = txn_amt - fee)ben_amt: 92025= 920.25 EUR (credit to beneficiary after FX)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.
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",
"txn_amt": 100000,
"txn_ccy": "EUR",
"created_at": "2024-03-21T10:00:00Z",
"updated_at": "2024-03-21T10:00:00Z"
}
],
"total": 1,
"total_unfiltered": 5,
"has_more": false
}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",
"txn_amt": 100000,
"txn_ccy": "EUR",
"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"
}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
Create Inward Transfer (IWT)
POST /v1/transfers/iwt
Create an inward transfer (IWT) with special processing for incoming transfers.
Request Body:
{
"tenant_id": "123e4567-e89b-12d3-a456-426614174000",
"product_id": "123e4567-e89b-12d3-a456-426614174001",
"customer_type": "public",
"channel": "SIC",
"currency": "EUR",
"amount": 500000,
"fee_amount": 500,
"description": "Inward transfer",
"ori_account_id": "123e4567-e89b-12d3-a456-426614174010",
"ori_customer_id": "123e4567-e89b-12d3-a456-426614174011",
"ori_name": "Originator Name",
"ori_iban": "CH9300762011623852957",
"ben_account_id": "123e4567-e89b-12d3-a456-426614174020",
"ben_customer_id": "123e4567-e89b-12d3-a456-426614174021",
"ben_name": "Beneficiary Name",
"ben_iban": "CH9300762011623852958"
}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).
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
Get Event Details
GET /v1/transfers/{transfer_id}/events/{event_type}
Get details and preview of what a DSL event would execute.
Execute Event
POST /v1/transfers/{transfer_id}/events/{event_type}/execute
Manually trigger a DSL event for a transfer.
Request Body:
{
"dry_run": false,
"force": false,
"reason": "Manual execution requested",
"payload": {
"additional_data": "value"
}
}Set Manual FX Rate
PATCH /v2/transfers/{transfer_id}/fx-rate
Manually override the FX rate for a transfer. Requires operator permissions.
Request Body:
{
"fx_rate": 1.0850,
"reason": "Market conditions require manual rate"
}Sign Transfer
POST /v1/transfers/{transfer_id}/sign
Digitally sign a transfer for approval. Used for high-value transfers requiring signature.
KYT Webhook
POST /v1/kyt/webhook
Handle webhook from KYT provider (e.g., ComplyAdvantage) for compliance checks.
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
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 | — | Fee mode: OUR, BEN, SHA |
$context.fee_amount | int64 | MINOR | Fee amount in debit currency |
$context.fee_currency | string | — | Fee currency code |
Fee Modes (ISO 20022):
| Mode | Description | Who Pays |
|---|---|---|
OUR | Sender pays all fees | Originator |
BEN | Beneficiary pays all fees | Deducted from credit amount |
SHA | Shared fees | Split between parties |
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 | Description |
|---|---|
transfers_m.invalid_product_id | Invalid or missing product ID |
transfers_m.invalid_amount | Invalid transfer amount |
transfers_m.insufficient_balance | Insufficient account balance |
transfers_m.dsl_execution_failed | DSL rule execution failed |
transfers_m.kyt_validation_failed | KYT validation failed |
transfers_m.invalid_status_transition | Invalid status transition attempted |
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 (optional, can be set by tariff) |
Fee Mode Calculation:
| Mode | Description | txn_netAmt | ben_amt |
|---|---|---|---|
OUR | Sender pays all fees | txn_amt - fee | convert(txn_netAmt) |
BEN | Beneficiary pays all fees | txn_amt | convert(txn_amt) - fee |
SHA | Shared fees | txn_amt - ori_fee | convert(txn_netAmt) - ben_fee |
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 |
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) |