Corebanq Public Docs
Transfers

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:

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

Transfer Types

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

Transfer Direction

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

Direction Types

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

Direction Determination Logic

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

1. Outgoing Transfer (outbound)

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

2. Incoming Transfer (inbound)

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

3. Internal Transfer (internal)

  • Condition: Both ori_account_id AND ben_account_id exist 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)
  • 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:

  1. If ori_account_id is provided:

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

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

Important Notes

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

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

Internal Transfer Legs

For internal transfers (type = "internal"), the system automatically creates two transfer records:

  1. Primary Leg (Outbound):

    • direction = "outbound"
    • ori_account_id = Originator's account
    • ben_account_id = Beneficiary's account
    • parent_id = null
  2. 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, status
  • metadata, context
  • Linked via parent_id for 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

StatusDescription
draftInitial state, transfer being prepared
pendingReady for processing
pre_kytBefore KYT validation (GL: suspense accounts)
post_kytAfter KYT validation passed
treasuryTreasury processing stage
in_transitTransfer in transit to destination
reconcileAwaiting reconciliation
completedSuccessfully completed (terminal)
failedFailed to process (terminal)
reversedTransfer reversed (terminal)
cancelledCancelled 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     reversed

Amount Representation (Minor Units)

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

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

  • txn_amt - Debit amount (from originator)
  • txn_ccy - Debit currency
  • ben_amt - Credit amount (to beneficiary, calculated based on FX and fee_mode)
  • ben_ccy - Credit currency
  • txn_feeAmt - Fee amount (in debit currency)
  • fee_mode - Fee policy: OUR (sender pays), BEN (beneficiary pays), SHA (shared)

Currency Precision Reference

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

Examples

USD (2 decimal places):

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

USDC (6 decimal places):

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

BTC (8 decimal places):

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

Common Mistakes

Wrong: Using Floating-Point Numbers

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

Wrong: Using Decimal Strings

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

Correct: Using Integers in Minor Units

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

Internal Architecture

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

DSL Event Amount Fields

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

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

Example DSL notification:

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

Example DSL calculation (balance check):

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

Endpoints

Create Transfer

POST /v1/transfers

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

Flow:

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

Request Body:

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

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

Locked quote consumption: 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 manualClient 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 schemaCreatePaymentRequest 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_id reference currencies.currencies; currency code fields remain for readability and compatibility.
  • fx_rate_id, fx_spread_rule_id, fx_tariff_id, transfer_tariff_id, and fee-range IDs identify the pricing rules used for the quote.

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

MVP scope:

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

Create Transfer Quote

POST /v2/transfers/quotes

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

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

Headers:

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

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_walletAddress is required - system looks up customer by this address
  • bc_txHash is the on-chain transaction hash for tracking
  • No ori_account_id needed (external originator)

CRW - Crypto Withdrawal

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

Example: Crypto Withdrawal to External Wallet

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

Example: Crypto Withdrawal Converted to Fiat (IBAN)

{
  "product_code": "CRW",
  "txn_amt": 100000000,
  "txn_ccy": "USDC",
  "fee_mode": "OUR",
  "txn_feeAmt": 500000,
  "txn_netAmt": 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 currency
  • fee_mode: "OUR" = sender pays all fees
  • txn_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_id and ben_account_id are required
  • Same customer owns both accounts

INT - Internal Transfer (Within Core Banking System)

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

Example: Internal Transfer Between Customers

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

Key points:

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

IWT - Incoming Wire Transfer

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

Example: Incoming Wire Transfer

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

Key points:

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

OWT - Outgoing Wire Transfer

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

Example: Simple Outgoing Wire Transfer

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

Example: Outgoing Wire with Fees and FX Conversion

{
  "product_code": "OWT",
  "txn_amt": 100000,
  "txn_ccy": "USD",
  "fee_mode": "OUR",
  "txn_feeAmt": 450,
  "txn_netAmt": 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 currency
  • fee_mode: "OUR" = sender pays all fees
  • txn_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_code can be used as an alternative to product_id. If the product code is not found, the query returns no results.

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

  • Examples: sort=-created_at, sort=created_at

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

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

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

Example Request

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

Response (200 OK):

{
  "data": [
    {
      "id": "123e4567-e89b-12d3-a456-426614174100",
      "product_id": "123e4567-e89b-12d3-a456-426614174001",
      "product_code": "CRD",
      "ori_customer_id": "123e4567-e89b-12d3-a456-426614174050",
      "type": "sepa",
      "status": "pending",
      "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
  • sort (optional): sort field (prefix with - for descending)
    • Examples: sort=-created_at, sort=transfer_id
  • limit (optional): number of items per page (default: 20, max: 100)
  • offset (optional): starting position for pagination (default: 0)
  • stack (optional): return stacked data instead of a flat list (e.g. stack=status or stack=created_at[YYYY-MM-DD])

Example Request

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

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-kyt
      • search.event_type.nin=status_change (exclude status change rows)
      • search.created_at.gte=2026-01-01 00:00:00 +00:00
  • sort (optional): sort field (prefix with - for descending)
    • Examples: sort=-created_at, sort=event_type
  • limit (optional): number of items per page (default: 20, max: 100)
  • offset (optional): starting position for pagination (default: 0)
  • stack (optional): return stacked data instead of a flat list (e.g. stack=event_type or stack=created_at[YYYY-MM-DD])

Example Request

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

Response (200 OK):

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

List Transfer Events

GET /v1/transfers/{transfer_id}/events

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

Response (200 OK):

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

Notes:

  • Event labels (name and description) are dynamically extracted from the transfer's context events:kv-list assignment (defined in DSL init event)
  • Only init and complete events have hardcoded labels; all other events use DSL-defined labels
  • If no label is found in DSL, the event type code is used as the display name
  • Only successfully executed events (no errors) are returned
  • The init event cannot be rerun; all other events can be rerun by default

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:

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

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

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

Event Labels and Descriptions:

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

Event Execution:

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

DSL Context Variables

When DSL rules execute, the following variables are available:

Identity & Status Fields

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

Customer & Account Fields

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

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

Amount Semantic Model

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

Amount Fields — MAJOR UNITS (for display/notifications)

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

Amount Fields — MINOR UNITS (for calculations/ledger)

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

Currency Fields

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

FX & Rate Fields

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

Reference Fields

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

KYT/Compliance Fields

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

Metadata

VariableTypeDescription
@event.metadataobjectTransfer metadata JSON

DSL Params ($params.*)

VariableTypeUnitsDescription
$params.event_idstringCurrent event type (e.g., "init", "swap")
$params.txn_idstringTransfer UUID
$params.customer_idstringCustomer UUID
$params.system_ratefloat64RATIOSystem FX rate (auto-fetched)
$params.fx_ratefloat64RATIOManual FX rate (from question answer)
$params.answerobjectQuestion form answer data

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


DSL Context ($context.*)

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

Originator Context ($context.ori.*)

VariableTypeUnitsDescription
$context.ori.customer.idUUIDCustomer UUID
$context.ori.customer.namestringCustomer name
$context.ori.customer.typestringCustomer type
$context.ori.customer.statusstringCustomer status
$context.ori.customer.metadataobjectCustomer metadata
$context.ori.account.idUUIDAccount UUID
$context.ori.account.currencystringAccount currency (e.g., "EUR")
$context.ori.account.balanceint64MINORAvailable balance in minor units
$context.ori.account.balance_fmtstringFormatted balance (e.g., "15.00 EUR")
$context.ori.account.ledgerstringSubledger code (e.g., "20212-88b0a9d3")

Beneficiary Context ($context.ben.*)

VariableTypeUnitsDescription
$context.ben.customer.idUUIDCustomer UUID
$context.ben.customer.namestringCustomer name
$context.ben.customer.typestringCustomer type
$context.ben.customer.statusstringCustomer status
$context.ben.customer.metadataobjectCustomer metadata
$context.ben.account.idUUIDAccount UUID
$context.ben.account.currencystringAccount currency
$context.ben.account.balanceint64MINORAvailable balance in minor units
$context.ben.account.balance_fmtstringFormatted balance
$context.ben.account.ledgerstringSubledger code

Transfer-level Context

VariableTypeUnitsDescription
$context.precision_factorint6410^(txn_decimals - ori_decimals) for currency conversion
$context.txn_ccy_decimalsintDebit currency decimals (e.g., 6 for USDC)
$context.ori_ccy_decimalsintAccount currency decimals (e.g., 2 for EUR)

Fee Context

VariableTypeUnitsDescription
$context.fee_modestringFee mode: OUR, BEN, SHA
$context.fee_amountint64MINORFee amount in debit currency
$context.fee_currencystringFee currency code

Fee Modes (ISO 20022):

ModeDescriptionWho Pays
OURSender pays all feesOriginator
BENBeneficiary pays all feesDeducted from credit amount
SHAShared feesSplit 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 CaseUnitsField Example
Display to userMAJOR@event.txn_amt → "126"
Notifications/emailsMAJOR@event.txn_amt → "126 USDC"
Ledger GL entriesMINOR@event.txn_amt_minor → 126000000
Arithmetic calculationsMINOR@event.txn_amt_minor * rate
Balance checksMINORCompare with $context.ori.account.balance
Precision conversionDivide 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

CodeDescription
transfers_m.invalid_product_idInvalid or missing product ID
transfers_m.invalid_amountInvalid transfer amount
transfers_m.insufficient_balanceInsufficient account balance
transfers_m.dsl_execution_failedDSL rule execution failed
transfers_m.kyt_validation_failedKYT validation failed
transfers_m.invalid_status_transitionInvalid 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 status
  • search.product_code=CRD - Filter by product code
  • search.txn_ccy=EUR - Filter by currency
  • search.ori_customer_id=123e4567-e89b-12d3-a456-426614174050 - Filter by originator customer ID
  • search.ben_customer_id=123e4567-e89b-12d3-a456-426614174050 - Filter by beneficiary customer ID
  • search.customer_id=123e4567-e89b-12d3-a456-426614174050 - Deprecated: Use search.ori_customer_id. Filter by originator customer ID

Supported Fields:

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

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

Sort Parameters

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

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

Examples:

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

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

Direction: Prefix with - for descending, omit for ascending

Pagination

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

Example Query

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

Authentication

All endpoints require JWT authentication via Bearer token:

Authorization: Bearer 
Accept-Language: en

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

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

Field Reference

Required Fields by Transfer Type

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

Common Fields

Identity & Product Fields

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

Amount Fields (API Request/Response — MINOR UNITS)

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

Currency Fields

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

Fee Mode Field

FieldTypeDescription
fee_modestringFee mode: OUR, BEN, SHA (optional, can be set by tariff)

Fee Mode Calculation:

ModeDescriptiontxn_netAmtben_amt
OURSender pays all feestxn_amt - feeconvert(txn_netAmt)
BENBeneficiary pays all feestxn_amtconvert(txn_amt) - fee
SHAShared feestxn_amt - ori_feeconvert(txn_netAmt) - ben_fee

Account Fields

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

Blockchain Fields

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

Units Summary

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

Available Balance Management

Balance Fields — All in MINOR UNITS

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

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

Banking Best Practices

In banking systems, there are two types of balances:

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

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

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

Current Implementation

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

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

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

Best Practice for KYT-Cleared Transactions

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

Current Flow (CRD Example)

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

  2. before-kyt event:

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

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

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

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

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

Implementation:

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

Option 2: Ledger Type-Based Logic

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

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

Option 3: DSL-Controlled Available Balance

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

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

Account Balance Display

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

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

Example: CRD Flow with Proper Available Balance

Before KYT (before-kyt event):

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

After KYT Cleared (kyt-green event):

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

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

Implementation Notes

The current code in service implementation updates both balances:

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

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

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

Permissions

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

On this page

OverviewCore ConceptsDSL-Driven WorkflowsTransfer TypesTransfer DirectionDirection TypesDirection Determination LogicAccount ValidationImportant NotesInternal Transfer LegsExamples by ScenarioTransfer StatusesStatus TransitionsAmount Representation (Minor Units)Currency Precision ReferenceExamplesCommon MistakesInternal ArchitectureDSL Event Amount FieldsEndpointsCreate TransferFinalize transfer (after pre-flight discovery)Schedule transferTransfer QuotesCreate Transfer QuoteGet Transfer QuoteConsume Quote in Create TransferPayload Examples by Transfer TypeCRD - Crypto DepositCRW - Crypto WithdrawalOWN - Internal Transfer Between Own AccountsINT - Internal Transfer (Within Core Banking System)IWT - Incoming Wire TransferOWT - Outgoing Wire TransferList TransfersQuery ParametersExample RequestGet Transfer by IDUpdate TransferCancel TransferDelete TransferGet Status HistoryList Transfer Status ChangesQuery ParametersExample RequestCreate Inward Transfer (IWT)List Available EventsQuery ParametersExample RequestList Transfer EventsGet Event DetailsExecute EventSet Manual FX RateSign TransferKYT WebhookDSL IntegrationEvent TypesDSL Context VariablesIdentity & Status FieldsCustomer & Account FieldsAmount Semantic ModelAmount Fields — MAJOR UNITS (for display/notifications)Amount Fields — MINOR UNITS (for calculations/ledger)Currency FieldsFX & Rate FieldsReference FieldsKYT/Compliance FieldsMetadataDSL Params ($params.*)DSL Context ($context.*)Originator Context ($context.ori.*)Beneficiary Context ($context.ben.*)Transfer-level ContextFee ContextExample DSL RuleError HandlingError Response FormatCommon Error CodesQuery ParametersSearch ParametersSort ParametersPaginationExample QueryAuthenticationField ReferenceRequired Fields by Transfer TypeCommon FieldsIdentity & Product FieldsAmount Fields (API Request/Response — MINOR UNITS)Currency FieldsFee Mode FieldAccount FieldsBlockchain FieldsUnits SummaryAvailable Balance ManagementBalance Fields — All in MINOR UNITSBanking Best PracticesCurrent ImplementationBest Practice for KYT-Cleared TransactionsCurrent Flow (CRD Example)Recommended ApproachAccount Balance DisplayExample: CRD Flow with Proper Available BalanceImplementation NotesPermissions