Docs/Ledger APIv0.0.1-preview

Transfers

Single-phase transfers, batches, tiered position-plus-settlement movement, and what a transfer result tells you.

On this page
  1. Submitting a transfer
  2. The result
  3. Batches and pipelining
  4. Tiered transfers
  5. Rejecting a transfer on purpose

A transfer debits one account and credits another in a single atomic step. There is no state in which one leg has applied and the other hasn’t — not mid-request, not after a crash, not on a replica.

Submitting a transfer

Every transfer carries the operation identity tuple plus the movement itself:

Field Meaning
source_id The submitting channel (from the source registry)
ledger_id Target ledger
currency ISO 4217 numeric; must match both accounts
txn_id Your reference — ≤ 36 printable ASCII chars, compared exactly
debit_account_id / credit_account_id 128-bit account ids
amount 64-bit integer, minor units, must be > 0
flags Optional behaviour flags
result = c.transfer(TransferCreate(
    source_id=1, ledger_id=1, currency=840,
    debit_account_id=1001, credit_account_id=1002,
    amount=2_500, txn_id="order-42",
))

Validation happens before anything is admitted: both accounts must exist, be active, allow the movement direction, share the transfer’s currency, and stay inside their limits, and the amount must not exceed the ledger’s max_txn_amount cap when one is set (ERR_AMOUNT_EXCEEDS_MAX). A failed check is a durable decline — see Errors and rejections.

When a request breaks more than one rule at once, the engine reports the consistency reason — wrong ledger, currency mismatch, a control-account rule — ahead of an account-eligibility one such as a closed account, on every path, batched or not. The durable rejection record carries the same reason.

The result

A TransferResult is a receipt with everything a caller needs to proceed without a follow-up read:

Field Meaning
status 0 accepted · 1 idempotent duplicate · else a decline code
phase POSTED, PENDING, VOIDED, EXPIRED
transfer_id The permanent derived identity of this transfer
batch_seq, batch_index The transfer’s position in the ledger’s total order
dr_posted_balance, dr_available_balance Debit account, after applying
cr_posted_balance, cr_available_balance Credit account, after applying
timestamp Hybrid logical clock — consistently ordered across nodes

(batch_seq, batch_index) is load-bearing: it’s the transfer’s coordinate in the committed order, the cursor for history pagination, and the sequence you can gate a read on for read-your-writes.

Batches and pipelining

On the wire, every write is a batch: a single transfer call is sent as a batch of one and answered with that item’s result, so a single write and a batched one share one write path and one set of rules. For throughput, submit transfers in batches — up to 1,024 per frame through the SDKs, with up to 64 frames in flight per connection:

results = c.transfer_batch(transfers, pipeline_depth=4)

Each entry gets its own status and result, in input order; one declined transfer never poisons its neighbors. And because every entry carries its own idempotency tuple, a failed or interrupted batch is safe to resubmit whole — entries that already landed come back as OK_DUPLICATE.

Tiered transfers

Ledgers in position–settlement mode move two tiers in one atomic transaction — four accounts, one amount, one txn_id:

result = c.tiered_transfer(TieredTransferCreate(
    source_id=1, ledger_id=2, currency=840, amount=10_000,
    dr_position_id=p1, cr_position_id=p2,
    dr_settlement_id=s1, cr_settlement_id=s2,
    txn_id="settle-batch-0042",
))

All four legs validate together and apply together — a limit breach on any leg rejects the whole transaction. This is how position tracking and settlement movement stay in one correctness domain instead of drifting apart across two services and a reconciliation job. Tiered reserve/commit/rollback exist too, mirroring the two-phase lifecycle, and the engine can produce a per-tier reconciliation whose discrepancy is — by construction — zero.

Rejecting a transfer on purpose

Sometimes the decline is the business outcome you need on the books — a payment refused by an upstream risk system, recorded so support and audit can see it. The reject operation records a durable rejected transaction without moving value:

c.reject(RejectCreate(
    source_id=1, currency=840, txn_id="order-43", ledger_id=1,
    reason_code=4207,   # your taxonomy — recorded verbatim on the record
    # Optional context, recorded as given:
    amount=2_500, debit_account_id=101, credit_account_id=202,
))

Because a reject moves no value, the only required fields are the four that form the operation identity: source_id, ledger_id, currency and txn_id. Always pass ledger_id explicitly: the Python SDK’s RejectCreate defaults it to 1, so an omitted ledger records the reject under ledger 1. Amount and account ids are optional context — supply what the declined request carried and the record keeps it, so the decline can be reconciled against the request that caused it. The accounts are not looked up on this path, so an account id that does not exist is accepted; frequently that is the very reason for the decline.

The division of authority is deliberate. The status is the engine’s: every explicit reject records result_code = ERR_UPSTREAM_REJECTED (41), so engine-validation statuses like ERR_LIMIT always mean the engine itself declined — callers can’t impersonate them. The reason is yours: reason_code is a free-form 32-bit value from your own taxonomy (a fraud-rule id, a compliance-policy code), recorded verbatim on the durable record and returned by every lookup and listing — so “every rejection under policy 4207 this week” is a query. For narrative detail beyond a code, your systems of record hold it, keyed by the same txn_id.

A rejected identity is remembered like any other: replaying it returns the original rejection rather than re-deciding it.