Skip to main content
PARIXDocs

Currency Exchange

Implement atomic currency conversion, explicit fees, liquidity controls, quote replay, reconciliation, and FX exposure monitoring on Parix.

Overview

Use Parix as the value-movement ledger behind a currency exchange service when one accepted quote must debit the source currency, collect any explicit fee, and credit the destination currency exactly once.

This guide converts 10000 USD cents into 9150 EUR cents and charges a separate 50 USD-cent service fee. The conversion is one linked TigerBeetle chain:

LegDebitCreditAmountLedger
Source principalCustomer USDUSD FX pool10000840
Service feeCustomer USDUSD fee revenue50840
Destination payoutEUR FX poolCustomer EUR9150978

The first two transfers carry the linked flag and the final transfer closes the chain. Either all three legs commit or none does.

Parix records the amounts selected by the quote service; it does not calculate market rates, spreads, rounding, sanctions decisions, or hedge orders. Keep the accepted quote and its pricing inputs in the application database.

Architecture and ownership

ComponentOwnsDoes not own
Exchange applicationCustomer workflow, quote lifecycle, rate source, spread, rounding, limits, stable IDs, and accepted quoteAuthoritative posted ledger balances
ParixCurrency accounts, immutable transfers, balance constraints, and linked-chain atomicityRate discovery, quote expiry policy, KYC, sanctions, or hedging
Market-data providerTradable or indicative rates, timestamps, and source provenanceCustomer balances or conversion settlement
Liquidity and treasury systemPool funding, exposure limits, hedge orders, and external cash settlementCustomer quote acceptance
ReconciliationMatching quotes, Parix transfers, pool positions, bank statements, and hedge fillsSilent mutation of committed transfers

Use a durable workflow or outbox between these systems. A Parix commit, a bank movement, and an external hedge are not one distributed transaction. The FX pools absorb the ledger exposure until treasury settles or hedges it.

Ledger model

Ledgers

Use one ledger for each currency and one integer unit for that ledger. This example uses ISO 4217 numeric currency codes as ledger IDs, but that convention is an application decision.

LedgerUnitPurpose
840USD centsCustomer USD balances, USD liquidity, and USD fees
978EUR centsCustomer EUR balances and EUR liquidity

Every individual transfer connects accounts in the same ledger. The linked batch may contain transfers from different ledgers, which makes the complete conversion atomic without pretending that USD cents and EUR cents are the same unit.

On a shared Developer database, each previously unseen external ledger can allocate a project-ledger mapping and consume ledger quota. Reserve the planned currency ledgers deliberately and use a dedicated production plan for production traffic.

Accounts

Credit-normal customer and liquidity accounts use history (8) plus debits_must_not_exceed_credits (2), for flags 10. This prevents customers and funded pools from spending more posted and pending value than they hold.

RoleExample IDLedgerCodeFlagsBalance meaning
USD treasury source8700000000000000018401008Reconciled source for controlled USD test funding
Customer USD87000000000000000284011010Customer spendable USD cents
USD FX pool87000000000000000384020010USD liquidity received and paid by conversions
USD fee revenue8700000000000000048402108Explicit exchange service fees
EUR treasury source8700000000000000059781008Reconciled source for controlled EUR pool funding
EUR FX pool87000000000000000697820010EUR liquidity received and paid by conversions
Customer EUR87000000000000000797811010Customer spendable EUR cents

The unconstrained treasury sources are modeling boundaries, not permission to invent external money. Gate their use behind a treasury service and reconcile every funding transfer to a bank, settlement, or approved test-funding record.

For production, create one customer account per customer and currency. Store the customer-to-account mapping outside TigerBeetle and avoid placing personally identifiable information in IDs or user-data fields.

Transfer codes

CodeEventDebitCredit
10Controlled pool fundingTreasuryCustomer or pool
300Exchange source principalCustomer sourceSource FX pool
301Exchange service feeCustomer sourceFee revenue
302Exchange destination payoutDestination FX poolCustomer destination
310Customer conversion reversalOriginal recipientsOriginal sources
320Reviewed liquidity adjustmentExcess positionDeficient position

Use different transfer IDs for every leg. Store one quote or conversion ID in user_data_128 on every leg so reconciliation can group the chain after execution; the linked relationship itself is not retained as a queryable grouping.

Quote and rounding policy

The application must turn a rate into integer source, destination, and fee amounts before it submits the chain. Never use binary floating-point math for financial amounts.

For example, represent 0.915000 EUR per USD with a scale of 1_000_000:

destination_minor = floor(source_minor × 915000 ÷ 1000000)

Define which side receives any remainder, when the spread is applied, whether the fee is inclusive or additional, and the minimum/maximum trade sizes. Persist the inputs, outputs, rounding rule, rate source, expiry, and accepted-at time with the quote.

Quote expiry is an application admission rule. Check it before durably accepting the quote. Once accepted, retries must use the original amounts and stable IDs even if the market rate or wall clock changes.

Invariants

InvariantEnforcement
One currency never changes unitPut every currency in a distinct ledger and use integer minor units
A conversion is all-or-nothingSubmit every principal, fee, and payout leg in one ordered batch; set linked on every non-final leg
A customer cannot overspendApply debits_must_not_exceed_credits to every customer currency account
A pool cannot pay unavailable fundsApply the same constraint to funded liquidity pools and reject/reprice when liquidity is insufficient
An accepted quote is immutablePersist exact amounts, rate metadata, and stable transfer IDs before the first write; never rewrite them during a retry
Duplicate delivery cannot duplicate valueLookup and reuse the same transfer IDs and exact immutable fields
Rates remain explainableKeep rate source, scale, spread, fee, rounding, and acceptance evidence in the application database
Exposure remains visibleReconcile each pool by currency against quotes, external settlement, and hedge records

Before you begin

You need:

  • a Parix database in Ready state and its immutable database UUID;
  • approved currency ledger IDs, minor units, account codes, transfer codes, and balance convention;
  • a fixed-point quote calculation with an explicit rounding and expiry policy;
  • funded source and destination liquidity accounts;
  • a durable quote record containing the three transfer IDs before the first Parix write;
  • an OAuth session for CLI work and a specific-database API key for the server-side service; and
  • reconciliation ownership for customer, pool, bank, market-data, and hedge records.

Use Developer only for learning and integration tests. Use Dedicated Single Node for isolated non-production workloads. Production traffic belongs on Production HA, Production 6, or a contract-defined Enterprise topology.

The public request schema accepts at most 8,190 records in an account, transfer, or lookup array. The selected plan can impose a lower events-per-request limit. Keep the entire linked conversion chain in one request and below the effective plan limit.

Dashboard walkthrough

  1. Open the intended organization and database. Confirm Ready, plan, database UUID, and effective event quotas.
  2. Select Connect and generate a Specific database API key for the exchange service. Store the one-time secret in a server-side secret manager.
  3. Open Query and choose Query accounts.
  4. Query ledger 840, then ledger 978, with a bounded limit. Shared queries require the ledger filter, and an unseen external ledger can allocate a namespace mapping.
  5. Confirm that customer and pool account codes match the application mapping before sending a conversion.
  6. Use Create accounts or Create transfers only in the intended development database and only with reviewed stable IDs.

Query accounts in the Parix Query explorer with ledger and limit controls

Query each currency separately. A zero-row result can mean the ledger has not been initialized; it does not prove that a different currency ledger is empty.

Live-write warning: Query can execute create_accounts and create_transfers. Those operations write immediately. Verify the selected environment, database UUID, currency ledger, account IDs, transfer IDs, amounts, codes, and linked-flag order before selecting Run.

CLI walkthrough

The commands below use the latest published @parix/cli package. The CLI uses browser OAuth and the active organization, so it is for developer and operator work rather than unattended production traffic.

npm install --global @parix/cli@latest
parix --version

parix auth login
parix auth status
parix database list --json

export PARIX_DATABASE_ID="db_replace_with_uuid"

Confirm that parix --version reports the installed package version. The CLI defaults to https://parix.io; use --base-url only when deliberately targeting another environment, because sessions and database IDs are environment-specific.

Create fx-accounts.json as a bare JSON array.

[
  { "id": "870000000000000001", "ledger": 840, "code": 100, "flags": 8 },
  { "id": "870000000000000002", "ledger": 840, "code": 110, "flags": 10 },
  { "id": "870000000000000003", "ledger": 840, "code": 200, "flags": 10 },
  { "id": "870000000000000004", "ledger": 840, "code": 210, "flags": 8 },
  { "id": "870000000000000005", "ledger": 978, "code": 100, "flags": 8 },
  { "id": "870000000000000006", "ledger": 978, "code": 200, "flags": 10 },
  { "id": "870000000000000007", "ledger": 978, "code": 110, "flags": 10 }
]

Submit the accounts and inspect the full response:

parix tb create-accounts "$PARIX_DATABASE_ID" --file ./fx-accounts.json --json

In this isolated example, fund the customer USD account with 100050 cents and the EUR pool with 500000 cents. Production funding must be driven by reconciled settlement events rather than ad hoc CLI commands.

parix tb create-transfers "$PARIX_DATABASE_ID" \
  --id 880000000000000001 \
  --from 870000000000000001 \
  --to 870000000000000002 \
  --amount 100050 \
  --ledger 840 \
  --code 10 \
  --json

parix tb create-transfers "$PARIX_DATABASE_ID" \
  --id 880000000000000002 \
  --from 870000000000000005 \
  --to 870000000000000006 \
  --amount 500000 \
  --ledger 978 \
  --code 10 \
  --json

Create accepted-fx-quote.json. Flags 1, 1, and 0 create one closed linked chain. Optional user_data_* groups the legs for later query (TigerBeetle indexes these fields; only non-zero values filter):

  • user_data_128 — accepted quote ID (the “what” that ties the multi-ledger legs)
  • user_data_64 — scaled rate snapshot used at acceptance (application-defined)
  • user_data_32 — quote-convention revision (application-defined)

Keep the full quote document, parties, and hedge instructions in the application database.

[
  {
    "id": "880000000000000003",
    "debit_account_id": "870000000000000002",
    "credit_account_id": "870000000000000003",
    "amount": "10000",
    "user_data_128": "890000000000000001",
    "user_data_64": "915000",
    "user_data_32": 1,
    "ledger": 840,
    "code": 300,
    "flags": 1
  },
  {
    "id": "880000000000000004",
    "debit_account_id": "870000000000000002",
    "credit_account_id": "870000000000000004",
    "amount": "50",
    "user_data_128": "890000000000000001",
    "user_data_64": "915000",
    "user_data_32": 1,
    "ledger": 840,
    "code": 301,
    "flags": 1
  },
  {
    "id": "880000000000000005",
    "debit_account_id": "870000000000000006",
    "credit_account_id": "870000000000000007",
    "amount": "9150",
    "user_data_128": "890000000000000001",
    "user_data_64": "915000",
    "user_data_32": 1,
    "ledger": 978,
    "code": 302,
    "flags": 0
  }
]

The example uses user_data_64 for a fixed-point rate snapshot and user_data_32 for the quote revision. Those conventions are application-defined; do not infer pricing from them without the durable quote record and its declared scale.

Submit and reconcile the accepted quote:

parix tb create-transfers "$PARIX_DATABASE_ID" --file ./accepted-fx-quote.json --json
parix tb lookup-transfers "$PARIX_DATABASE_ID" --ids 880000000000000003,880000000000000004,880000000000000005 --json
parix tb query-accounts "$PARIX_DATABASE_ID" --ledger 840 --limit 20 --json
parix tb query-accounts "$PARIX_DATABASE_ID" --ledger 978 --limit 20 --json

A successful create has persisted: true and an empty responsePayload. Item conflicts arrive as HTTP 409 with tbResults. The public response can expose only the first ten conflict entries, so always look up every stable ID rather than assuming an unlisted item succeeded or failed.

The CLI's --json output is terminal-logger decorated, not guaranteed clean stdout for a direct jq pipeline. Use the raw API for machine-to-machine automation.

Node.js implementation

@parix/tigerbeetle-node is the published Node.js client for TigerBeetle-shaped operations on the Parix HTTPS gateway. It does not open a native TigerBeetle connection. Pin a package version approved for your environment, configure { baseUrl, apiKey, databaseId } with a database-scoped API key, keep the client behind an application boundary, and validate behavior before production traffic. The adapter returns create result arrays and does not expose the raw gateway persisted field, so this example requires exact post-write lookup of every stable ID before advancing a durable workflow.

The following example assumes the quote has already passed rate, limit, eligibility, and expiry checks and has been durably accepted with stable IDs. Its lookup-first flow is safe after a process restart or ambiguous HTTP response.

import type {
  Account,
  CreateAccountResult,
  CreateTransferResult,
  Transfer,
} from '@parix/tigerbeetle-node';
import { AccountFlags, CreateTransferError, TransferFlags, createClient } from '@parix/tigerbeetle-node';

const client = createClient({
  baseUrl: process.env.PARIX_BASE_URL ?? 'https://parix.io',
  apiKey: mustGetEnv('PARIX_API_KEY'),
  databaseId: mustGetEnv('PARIX_DATABASE_ID'),
});

const ledger = { usd: 840, eur: 978 } as const;
const accountId = {
  usdTreasury: 870000000000000001n,
  customerUsd: 870000000000000002n,
  usdPool: 870000000000000003n,
  usdFeeRevenue: 870000000000000004n,
  eurTreasury: 870000000000000005n,
  eurPool: 870000000000000006n,
  customerEur: 870000000000000007n,
} as const;

const acceptedQuote = {
  id: 890000000000000001n,
  revision: 1,
  rateScaled: 915000n,
  sourceMinor: 10000n,
  feeMinor: 50n,
  destinationMinor: 9150n,
  transferId: {
    source: 880000000000000003n,
    fee: 880000000000000004n,
    destination: 880000000000000005n,
  },
} as const;

function account(id: bigint, ledgerId: number, code: number, flags: number): Account {
  return {
    id,
    debits_pending: 0n,
    debits_posted: 0n,
    credits_pending: 0n,
    credits_posted: 0n,
    user_data_128: 0n,
    user_data_64: 0n,
    user_data_32: 0,
    reserved: 0,
    ledger: ledgerId,
    code,
    flags,
    timestamp: 0n,
  };
}

function transfer(input: {
  id: bigint;
  debitAccountId: bigint;
  creditAccountId: bigint;
  amount: bigint;
  ledger: number;
  code: number;
  flags?: number;
  quoteId?: bigint;
  rateScaled?: bigint;
  quoteRevision?: number;
}): Transfer {
  return {
    id: input.id,
    debit_account_id: input.debitAccountId,
    credit_account_id: input.creditAccountId,
    amount: input.amount,
    pending_id: 0n,
    user_data_128: input.quoteId ?? 0n,
    user_data_64: input.rateScaled ?? 0n,
    user_data_32: input.quoteRevision ?? 0,
    timeout: 0,
    ledger: input.ledger,
    code: input.code,
    flags: input.flags ?? TransferFlags.none,
    timestamp: 0n,
  };
}

function getHttpStatus(cause: unknown): number | undefined {
  if (!cause || typeof cause !== 'object' || !('status' in cause)) return undefined;
  const status = (cause as { status?: unknown }).status;
  return typeof status === 'number' ? status : undefined;
}

function wasDefinitelyRejected(cause: unknown): boolean {
  const status = getHttpStatus(cause);
  return status !== undefined && [400, 401, 402, 403, 404, 409, 429].includes(status);
}

function sameAccount(actual: Account, intended: Account): boolean {
  return (
    actual.id === intended.id &&
    actual.user_data_128 === intended.user_data_128 &&
    actual.user_data_64 === intended.user_data_64 &&
    actual.user_data_32 === intended.user_data_32 &&
    actual.reserved === intended.reserved &&
    actual.ledger === intended.ledger &&
    actual.code === intended.code &&
    actual.flags === intended.flags
  );
}

function sameTransfer(actual: Transfer, intended: Transfer): boolean {
  return (
    actual.id === intended.id &&
    actual.debit_account_id === intended.debit_account_id &&
    actual.credit_account_id === intended.credit_account_id &&
    actual.amount === intended.amount &&
    actual.pending_id === intended.pending_id &&
    actual.user_data_128 === intended.user_data_128 &&
    actual.user_data_64 === intended.user_data_64 &&
    actual.user_data_32 === intended.user_data_32 &&
    actual.timeout === intended.timeout &&
    actual.ledger === intended.ledger &&
    actual.code === intended.code &&
    actual.flags === intended.flags
  );
}

async function accountsExistExactly(batch: Account[]): Promise<boolean> {
  const found = await client.lookupAccounts(batch.map(({ id }) => id));
  if (found.length === 0) return false;

  const intendedById = new Map(batch.map((item) => [item.id, item]));
  if (
    found.length !== batch.length ||
    !found.every((item) => {
      const intended = intendedById.get(item.id);
      return intended !== undefined && sameAccount(item, intended);
    })
  ) {
    throw new Error('FX account IDs are partially present or have different immutable fields');
  }
  return true;
}

async function transfersExistExactly(label: string, batch: Transfer[]): Promise<boolean> {
  const found = await client.lookupTransfers(batch.map(({ id }) => id));
  if (found.length === 0) return false;

  const intendedById = new Map(batch.map((item) => [item.id, item]));
  if (
    found.length !== batch.length ||
    !found.every((item) => {
      const intended = intendedById.get(item.id);
      return intended !== undefined && sameTransfer(item, intended);
    })
  ) {
    throw new Error(`${label} IDs are partially present or have different immutable fields`);
  }
  return true;
}

async function createAccountsOrReconcile(batch: Account[]): Promise<void> {
  if (await accountsExistExactly(batch)) return;

  let results: CreateAccountResult[] | CreateTransferResult[];
  try {
    results = await client.createAccounts(batch);
  } catch (cause) {
    if (wasDefinitelyRejected(cause)) throw cause;
    if (await accountsExistExactly(batch)) return;
    throw new Error('Account outcome is ambiguous; retry only with the same IDs and fields', { cause });
  }

  if (await accountsExistExactly(batch)) return;
  if (results.length === 0) {
    throw new Error('Account write was not confirmed by lookup; do not advance or change the payload');
  }
  throw new Error(
      `createAccounts rejected and lookup found no exact batch: ${JSON.stringify(results)}`,
    );
}

async function createTransfersOrReconcile(label: string, batch: Transfer[]): Promise<void> {
  if (await transfersExistExactly(label, batch)) return;

  let results: CreateAccountResult[] | CreateTransferResult[];
  try {
    results = await client.createTransfers(batch);
  } catch (cause) {
    if (wasDefinitelyRejected(cause)) throw cause;
    if (await transfersExistExactly(label, batch)) return;
    throw new Error(`${label} outcome is ambiguous; retry only with the same IDs and fields`, { cause });
  }

  if (await transfersExistExactly(label, batch)) return;
  if (results.length === 0) {
    throw new Error(`${label} was not confirmed by lookup; do not advance or change the payload`);
  }
  throw new Error(
      `${label} rejected${results.some((item) => item.result === CreateTransferError.exceeds_credits) ? ' (capacity)' : ''}` +
        ` and lookup found no exact batch: ${JSON.stringify(results)}`,
    );
}

async function settleAcceptedQuote(): Promise<void> {
  await createTransfersOrReconcile('accepted FX quote', [
    transfer({
      id: acceptedQuote.transferId.source,
      debitAccountId: accountId.customerUsd,
      creditAccountId: accountId.usdPool,
      amount: acceptedQuote.sourceMinor,
      ledger: ledger.usd,
      code: 300,
      flags: TransferFlags.linked,
      quoteId: acceptedQuote.id,
      rateScaled: acceptedQuote.rateScaled,
      quoteRevision: acceptedQuote.revision,
    }),
    transfer({
      id: acceptedQuote.transferId.fee,
      debitAccountId: accountId.customerUsd,
      creditAccountId: accountId.usdFeeRevenue,
      amount: acceptedQuote.feeMinor,
      ledger: ledger.usd,
      code: 301,
      flags: TransferFlags.linked,
      quoteId: acceptedQuote.id,
      rateScaled: acceptedQuote.rateScaled,
      quoteRevision: acceptedQuote.revision,
    }),
    transfer({
      id: acceptedQuote.transferId.destination,
      debitAccountId: accountId.eurPool,
      creditAccountId: accountId.customerEur,
      amount: acceptedQuote.destinationMinor,
      ledger: ledger.eur,
      code: 302,
      flags: TransferFlags.none,
      quoteId: acceptedQuote.id,
      rateScaled: acceptedQuote.rateScaled,
      quoteRevision: acceptedQuote.revision,
    }),
  ]);
}

async function main(): Promise<void> {
  const constrainedHistory = AccountFlags.history | AccountFlags.debits_must_not_exceed_credits;
  await createAccountsOrReconcile([
    account(accountId.usdTreasury, ledger.usd, 100, AccountFlags.history),
    account(accountId.customerUsd, ledger.usd, 110, constrainedHistory),
    account(accountId.usdPool, ledger.usd, 200, constrainedHistory),
    account(accountId.usdFeeRevenue, ledger.usd, 210, AccountFlags.history),
    account(accountId.eurTreasury, ledger.eur, 100, AccountFlags.history),
    account(accountId.eurPool, ledger.eur, 200, constrainedHistory),
    account(accountId.customerEur, ledger.eur, 110, constrainedHistory),
  ]);

  await createTransfersOrReconcile('test USD funding', [
    transfer({
      id: 880000000000000001n,
      debitAccountId: accountId.usdTreasury,
      creditAccountId: accountId.customerUsd,
      amount: 100050n,
      ledger: ledger.usd,
      code: 10,
    }),
  ]);
  await createTransfersOrReconcile('test EUR liquidity', [
    transfer({
      id: 880000000000000002n,
      debitAccountId: accountId.eurTreasury,
      creditAccountId: accountId.eurPool,
      amount: 500000n,
      ledger: ledger.eur,
      code: 10,
    }),
  ]);

  await settleAcceptedQuote();
}

function mustGetEnv(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing ${name}`);
  return value;
}

try {
  await main();
} finally {
  client.destroy();
}

Do the quote-expiry check before writing an accepted-quote record. A restarted worker must first reconcile the stable IDs even if the quote is now past its original expiry; rejecting that replay on the current clock can strand a conversion that already committed.

Failure and retry handling

Signal or conditionMeaningAction
Empty create result []Adapter reported no item conflictsRaw HTTPS: also require persisted: true. Node adapter: confirm every stable ID by exact post-write lookup before advancing
Nonempty create resultOne or more items were rejectedThe adapter unwraps HTTP 409 + tbResults into this array. Lookup every stable ID; linked legs must be observed as the complete exact chain or escalated
Source balance exceededCustomer lacks principal plus feeReturn insufficient funds; do not retry unchanged
Destination pool balance exceededAvailable liquidity cannot fund the payoutReject or reprice before acceptance; replenish through a separately reconciled treasury event
Quote expired before durable acceptanceThe customer no longer has a valid executable quoteProduce a new quote with new business identity and transfer IDs
Market rate changes after acceptancePricing moved after the business event became immutableSettle the accepted amounts; do not change the payload under the same transfer IDs
Linked leg failsTigerBeetle rejected the chainNo conversion leg commits; correct the deterministic cause before creating a new accepted quote
HTTP 400Strict schema, ledger, code, flag, or field validation failedFix the request; do not retry unchanged
HTTP 401, 402, 403, or 404Credential, billing, scope, environment, or database is wrongStop and correct configuration
HTTP 429Transient admission pressure or a durable quota was reachedBack off with jitter only for transient rate limits; wait for quota reset or change workload/plan for durable exhaustion
Timeout, disconnect, HTTP 500, or 503The write outcome may be ambiguousLookup all three IDs and exact immutable fields, then retry only the same absent payload
External hedge fails after customer settlementLedger conversion committed but treasury execution failedKeep the customer conversion immutable; manage and escalate pool exposure instead of silently rewriting or auto-reversing it
Approved customer reversalPolicy requires a compensating conversionPrice and submit new stable reversal legs; ensure returned value is still available and retain both histories

The Node adapter returns item conflicts as a non-empty result array rather than throwing them as transport errors. Keep result-array reconciliation outside the transport catch.

Test scenarios

Run tests in an isolated non-production database with dedicated credentials and ID namespaces.

ScenarioSetup/actionExpected result
Account bootstrapCreate all seven accountsEmpty result; repeat resolves to the exact existing accounts
Happy-path conversionFund customer and EUR pool, then submit the three linked legsAll three commit; USD principal and fee debit, EUR payout credits
Duplicate accepted quoteDeliver the same three IDs and payload twiceOne conversion exists; the second delivery reconciles without another movement
Transfer-ID collisionReuse a leg ID with a different amount or accountExact-field comparison rejects the collision
Insufficient customer USDSubmit principal plus fee above available USDEntire chain fails; no fee and no EUR payout
Insufficient EUR liquiditySubmit a destination payout above the pool balanceEntire chain fails; customer USD remains unchanged
Invalid final accountUse a missing customer EUR account on the final legSource principal and fee also roll back
Open linked chainSet linked on the final legChain is rejected with no movement
Unlinked fee legOmit linked from the principal or fee before the final legTest must fail policy validation before submission; never send a partial chain
Quote rounding boundaryPrice a source amount that leaves a fixed-point remainderDestination follows the documented rounding rule exactly
Quote expires before acceptanceAdvance the application clock beyond expiry before durable acceptanceNo ledger request is sent
Rate moves after acceptanceChange market data before retrying an accepted quoteRetry uses original amounts and IDs
Ambiguous responseDrop the HTTP response after submissionLookup confirms the full exact chain or retry uses the same payload
Shared query without ledgerQuery a Developer database without ledgerRequest is rejected; queries with 840 and 978 succeed
Ledger quotaAllocate more external currency ledgers than the Developer plan permitsDurable quota error; no blind retry
Hedge failureCommit the customer conversion and fail the external hedge definitivelyCustomer trade remains posted; exposure incident and treasury workflow open
Environment isolationRun sample IDs against test credentialsOnly the test database changes

Production operations

  • Keep quote calculation, market-data source, spread, fee, rounding, expiry, customer consent, and stable-ID mapping in a durable application record.
  • Reconcile each accepted quote to its exact Parix transfer IDs. Reconcile pool balances separately to bank settlement and hedge fills.
  • Monitor spendable liquidity by currency, pool concentration, stale quotes, rate-source health, rejected chains, ambiguous outcomes, ledger mapping quota, and exposure beyond treasury limits.
  • Stop quoting a pair before the destination pool reaches its hard balance constraint. The ledger constraint is the final guardrail, not the primary liquidity-control loop.
  • Use separate API keys, databases, ID namespaces, pool accounts, alerting, and reconciliation jobs for test and production.
  • Restrict treasury funding, pool adjustments, fee adjustments, and reversals to reviewed least-privilege services.
  • Scale hot pools deliberately. Sharding a pool changes routing and reconciliation requirements; never create untracked liquidity fragments merely to avoid a hot account.
  • Batch below both the 8,190 public-schema maximum and the active plan limit. Never split one linked conversion chain across requests.
  • Use a durable outbox/inbox for accepted quotes and settlement work. Preserve the same payload across process restarts and retries.
  • Practice market-data outages, depleted-pool controls, key rotation, ambiguous-write reconciliation, and hedge-provider failure before production launch.