Skip to main content
PARIXDocs

Neobank / BaaS

Build customer, omnibus, settlement, suspense, and payment-authorization ledgers on Parix.

Overview

This guide implements a single-currency neobank or banking-as-a-service sub-ledger with customer balances, an omnibus control account, processor settlement, and suspense. It also implements the complete card-style pending lifecycle: authorize, then post or void.

The examples use ledger 840 for USD, amounts in cents, and a five-minute pending timeout. Replace every ledger, code, timeout, and ID with values from your reviewed program design. Do not reuse the example IDs in more than one database.

Parix is managed ledger infrastructure. It is not a banking license, sponsor bank, KYC or AML provider, sanctions engine, card processor, payment network, general ledger, or regulatory-reporting system.

Architecture and ownership

Keep the regulated-program boundary explicit:

ComponentOwns
Neobank or BaaS applicationCustomer authentication, KYC state, sanctions and fraud decisions, product limits, stable event IDs, payment state machine, statements, disputes, and customer-facing reads
Sponsor bank and processorsExternal account custody, card or bank-rail execution, network messages, clearing, and settlement reports
ParixAuthenticated HTTP gateway routing and the managed TigerBeetle database
TigerBeetle ledgerImmutable accounts and transfers, pending and posted balances, account constraints, timeout behavior, and ordered history
Finance and compliance operationsReconciliation, suspense resolution, approved adjustments, general-ledger posting, reporting, and evidence retention

Store names, emails, addresses, KYC evidence, sanctions results, account and routing numbers, PAN data, and processor payloads outside TigerBeetle. Ledger IDs and user-data fields must be opaque identifiers with no PII. Keep the mapping to regulated records in access-controlled application storage.

The Node adapter and all public TigerBeetle operations use this path:

regulated server application -> Parix API -> authenticated private gateway -> managed TigerBeetle

They do not use cluster_id, replica addresses, or a native direct connection. A processor action and a ledger action are separate distributed-system steps; neither Parix nor a linked TigerBeetle chain makes an external payment rail atomic with the ledger.

Ledger model

Ledger

LedgerAssetUnitRule
840USDcentsEvery account and transfer in this example uses ledger 840. Reconcile it independently from every other currency or asset.

Never transfer directly across ledgers. Model foreign-exchange execution as separately authorized and reconciled movements in each asset ledger.

Account codes

CodeRoleBalance convention and controlFlags
200Customer liabilitySpendable posted balance is credits minus debits; pending debits reserve available fundshistory | debits_must_not_exceed_credits (10)
210Omnibus cash controlInternal control position compared with sponsor-bank statementshistory (8)
220Processor settlementAuthorizations and presentments accumulate against processor clearing and settlement reportshistory (8)
230SuspenseTemporary, reviewed discrepancies only; every item needs an owner and aging deadlinehistory (8)

The customer constraint is the concurrent no-overdraft control. The omnibus, settlement, and suspense accounts are controlled by workflow authorization and reconciliation because their positions may legitimately move on either side.

Transfer codes

CodeEventDebit accountCredit accountLifecycle
2000Customer fundingOmnibusCustomerPosted after the program's external-finality rule is met
2010Card paymentCustomerSettlementPending authorization, then full or partial post, or void, with the same code
2011Posted payment returnSettlementCustomerNew compensating transfer after a payment has already posted
2020Settlement sweepSettlementOmnibusPosted from a reconciled processor or sponsor-bank settlement event
2030Move discrepancy to suspenseExpected source accountSuspenseApproved operational transfer with a case reference outside the ledger
2031Resolve suspenseSuspenseApproved destination accountApproved compensating transfer; never delete or rewrite the original discrepancy

Pending, post, and void records

The authorization is a pending transfer with flags = pending (2) and timeout = 300. TigerBeetle timeout values are seconds, so 300 means five minutes.

A later post or void is a new transfer with a new stable ID. It must include:

  • pending_id equal to the original authorization transfer ID;
  • the original external debit_account_id and credit_account_id;
  • the original ledger and code;
  • for a full post or void, the original amount; for a partial post, the smaller presentment amount, which must not exceed the pending amount;
  • timeout = 0; and
  • exactly one lifecycle flag: post_pending_transfer (4) or void_pending_transfer (8).

Including the original fields is especially important on Shared Developer, where Parix maps external account and ledger identifiers into a project namespace. Do not copy native examples that zero-fill those fields. A partial post is terminal for the pending transfer: it posts the supplied amount and releases the unposted remainder. Post and void are mutually exclusive outcomes; never send both for one authorization.

This guide applies a product rule that a post amount must be positive. TigerBeetle 0.17.6 also accepts a zero-amount post, which posts no value and releases the full pending remainder. Reject zero in the payment application before submission so cancellation uses the explicit void path and remains distinguishable in reconciliation.

If a presentment arrives after the authorization expired, the pending transfer can no longer be posted. Reconcile the processor event and apply the program's reviewed late-presentment policy; do not create a replacement authorization merely to hide the expiry. After a payment has posted, a reversal or return is a new stable code-2011 compensating transfer from settlement to customer, not a void of the completed authorization.

Invariants

InvariantEnforcement
IDs are opaque, stable, and nonzeroPersist account, authorization, post, void, funding, settlement, and adjustment IDs before their first write. Do not encode PII.
Amounts are positive integer minor unitsValidate the currency exponent and amount before constructing a transfer. Never use floating point for ledger value.
Customer debits cannot exceed customer creditsApply debits_must_not_exceed_credits; treat the resulting nonempty create result as a decline, not a transient platform error.
One authorization has one terminal outcomeSerialize or deduplicate processor events by the original authorization and use distinct, stable post/void IDs. Reconcile any competing outcome.
Lifecycle fields remain consistentCopy the original account IDs, ledger, and code into the post or void record and set its pending_id. Use the original amount for a full post or void, or a smaller amount for a partial post. Enforce this guide's positive-post rule in the application.
Timeout units and limits are explicitStore timeout policy in seconds and validate it against the current plan limit before writing. Expiration releases ledger pending balances but does not update an external processor.
Corrections are immutableUse a reviewed compensating or suspense transfer. Never mutate historical records.
Atomicity is boundedOnly events in one linked chain in one request are atomic. Authorization and its later post/void, separate API calls, and external provider operations are not one transaction.

Before you begin

  1. Choose the correct plan. Developer and Dedicated Single Node are non-production plans. Use a Production or Enterprise plan for production workloads. Review Plans and limits for topology, quotas, pending-transfer timeout, backup posture, and support.
  2. Create a database, wait for Ready, and record its UUID. Public API and CLI paths use the database UUID, not its display name.
  3. Obtain reviewed ledger, account-code, transfer-code, timeout, and ID-allocation decisions from engineering, finance, operations, and compliance.
  4. Define the processor event state machine, including duplicate, delayed, out-of-order, expired, partial, reversal, return, and dispute handling.
  5. Define customer, omnibus, settlement, and suspense reconciliation before processing real value.
  6. Generate a database-scoped API key for the server integration and store it only in server-side secret storage. Dashboard and CLI users authenticate separately.

@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. Its configuration is { baseUrl, apiKey, databaseId }.

Public create and lookup requests are bare JSON arrays containing 1–8190 items. Shared Developer can enforce lower per-request and plan limits. In raw HTTP JSON, use decimal strings for IDs, amounts, timestamps, and other bigint-width values so JavaScript cannot round them.

Dashboard walkthrough

Live-write warning: The Query explorer operates on the selected live database. Create accounts and Create transfers persist immediately and cannot be undone. Use an isolated non-production database, confirm the organization and database, and record stable IDs before selecting Run.

  1. Open Dashboard and confirm Ready, the intended non-production plan, and the database UUID.
  2. Open Query, select Create accounts, clear Generate random ID when using a governed ID, and create the omnibus, customer, settlement, and suspense accounts one at a time.
  3. Use ledger 840; codes 210, 200, 220, and 230; flags 8 for control accounts; and flags 10 for the constrained customer account.
  4. Confirm that every successful account create returns zero result rows. Investigate any nonempty create-result array before proceeding.
  5. Fund the test customer with a posted code-2000 transfer from omnibus to customer.
  6. Create an authorization from customer to settlement. In advanced fields, set code 2010, flags 2, and timeout 300 seconds. Record the authorization transfer ID.
  7. To post in full, create a new transfer with flags 4, timeout 0, pending_id set to the authorization ID, and the authorization's original debit account, credit account, amount, ledger, and code. For a partial presentment, use the smaller captured amount; the remainder is released. To void instead, use a new ID and flags 8 with the original full amount and other original fields. Never execute more than one terminal alternative.
  8. Use Lookup transfers to confirm the authorization and terminal event by ID.
  9. Use Query accounts and Query transfers for broader inspection. On Shared Developer, select ledger 840; shared queries reject a missing ledger.

The Parix Query explorer Query accounts form showing limit, ledger, code, Run, and an empty result area

The screenshot shows the Query accounts read form before execution. Its All known ledgers selection is not valid for a Shared Developer query; explicitly select the application's ledger there.

The dashboard is for controlled smoke tests and investigation. It is not a processor integration, production transaction engine, or service-authentication mechanism.

CLI walkthrough

These commands use the latest published parix CLI syntax. The CLI is an operator and development tool authenticated by browser OAuth. Never copy its local OAuth session into a production service.

Install, verify, and authenticate:

npm install -g @parix/cli@latest
parix --version
parix auth login
parix auth status

parix --version should report the installed package version for this walkthrough. Replace <database-id> with the database UUID in every command.

Create the accounts

Save this reviewed bare array as neobank-accounts.json:

[
  {
    "id": "91000000000000000001",
    "debits_pending": "0",
    "debits_posted": "0",
    "credits_pending": "0",
    "credits_posted": "0",
    "user_data_128": "0",
    "user_data_64": "0",
    "user_data_32": 0,
    "reserved": 0,
    "ledger": 840,
    "code": 210,
    "flags": 8,
    "timestamp": "0"
  },
  {
    "id": "91000000000000000002",
    "debits_pending": "0",
    "debits_posted": "0",
    "credits_pending": "0",
    "credits_posted": "0",
    "user_data_128": "0",
    "user_data_64": "0",
    "user_data_32": 0,
    "reserved": 0,
    "ledger": 840,
    "code": 200,
    "flags": 10,
    "timestamp": "0"
  },
  {
    "id": "91000000000000000003",
    "debits_pending": "0",
    "debits_posted": "0",
    "credits_pending": "0",
    "credits_posted": "0",
    "user_data_128": "0",
    "user_data_64": "0",
    "user_data_32": 0,
    "reserved": 0,
    "ledger": 840,
    "code": 220,
    "flags": 8,
    "timestamp": "0"
  },
  {
    "id": "91000000000000000004",
    "debits_pending": "0",
    "debits_posted": "0",
    "credits_pending": "0",
    "credits_posted": "0",
    "user_data_128": "0",
    "user_data_64": "0",
    "user_data_32": 0,
    "reserved": 0,
    "ledger": 840,
    "code": 230,
    "flags": 8,
    "timestamp": "0"
  }
]

Submit it once:

parix tb create-accounts <database-id> --file ./neobank-accounts.json --json

A successful create has responsePayload: []. HTTP 409 conflicts contain a tbResults array indexed to the submitted bare array; do not treat a nonempty result as success.

Fund and authorize

Fund the test customer with a stable posted transfer ID:

parix tb create-transfers <database-id> \
  --id 92000000000000000001 \
  --from 91000000000000000001 \
  --to 91000000000000000002 \
  --amount 10000 \
  --ledger 840 \
  --code 2000 \
  --json

Save the pending authorization as neobank-authorization.json. The advanced lifecycle fields are explicit, and the timeout is in seconds:

[
  {
    "id": "92000000000000000002",
    "debit_account_id": "91000000000000000002",
    "credit_account_id": "91000000000000000003",
    "amount": "2500",
    "pending_id": "0",
    "user_data_128": "0",
    "user_data_64": "0",
    "user_data_32": 0,
    "timeout": 300,
    "ledger": 840,
    "code": 2010,
    "flags": 2,
    "timestamp": "0"
  }
]
parix tb create-transfers <database-id> --file ./neobank-authorization.json --json

For pending timeouts and advanced lifecycle fields, prefer a reviewed file. The TigerBeetle payload unit is seconds.

Post or void

For a processor approval, save this bare array as neobank-post.json:

[
  {
    "id": "92000000000000000003",
    "debit_account_id": "91000000000000000002",
    "credit_account_id": "91000000000000000003",
    "amount": "2500",
    "pending_id": "92000000000000000002",
    "user_data_128": "0",
    "user_data_64": "0",
    "user_data_32": 0,
    "timeout": 0,
    "ledger": 840,
    "code": 2010,
    "flags": 4,
    "timestamp": "0"
  }
]

For a processor decline or cancellation, use this alternative neobank-void.json instead:

[
  {
    "id": "92000000000000000004",
    "debit_account_id": "91000000000000000002",
    "credit_account_id": "91000000000000000003",
    "amount": "2500",
    "pending_id": "92000000000000000002",
    "user_data_128": "0",
    "user_data_64": "0",
    "user_data_32": 0,
    "timeout": 0,
    "ledger": 840,
    "code": 2010,
    "flags": 8,
    "timestamp": "0"
  }
]

For an approval, run only the post command:

parix tb create-transfers <database-id> --file ./neobank-post.json --json

For a cancellation, run this void command instead. Do not run it for an authorization that has already taken the approval path:

parix tb create-transfers <database-id> --file ./neobank-void.json --json

Settlement sweep, return, and suspense

After a posted presentment is reconciled to a processor settlement event, sweep the settlement account into omnibus with a new stable ID:

parix tb create-transfers <database-id> \
  --id 92000000000000000005 \
  --from 91000000000000000003 \
  --to 91000000000000000001 \
  --amount 2500 \
  --ledger 840 \
  --code 2020 \
  --json

A posted-payment return uses code 2011 (settlement → customer), not a void of the completed authorization:

parix tb create-transfers <database-id> \
  --id 92000000000000000006 \
  --from 91000000000000000003 \
  --to 91000000000000000002 \
  --amount 500 \
  --ledger 840 \
  --code 2011 \
  --json

Move a reviewed discrepancy into suspense with code 2030, then resolve it later with code 2031 and a new transfer ID. Keep the case reference in the application database. Leave user_data_* at zero unless you will query by an opaque processor-event or case correlation; do not put the ledger number in user_data_32.

If any create command has an ambiguous HTTP outcome, look up its original stable ID before retrying:

parix tb lookup-transfers <database-id> \
  --id 92000000000000000001 \
  --id 92000000000000000002 \
  --id 92000000000000000003 \
  --id 92000000000000000004 \
  --json

Accept an exact match as committed. Retry an absent event only when the failure is retryable, and only with the identical ID and payload.

Inspect the customer's history and the ledger-scoped payment population:

parix tb get-account-transfers <database-id> \
  --account-id 91000000000000000002 \
  --limit 100 \
  --flag debits \
  --flag credits \
  --json

parix tb query-transfers <database-id> \
  --ledger 840 \
  --code 2010 \
  --limit 100 \
  --json

Shared queries require --ledger; use it consistently on every plan.

Node.js implementation

The published adapter exposes promise-based TigerBeetle-shaped methods over HTTP. Install a pinned package version:

npm install @parix/tigerbeetle-node

This example uses complete objects and bigint, keeps IDs stable, rejects nonempty create results, performs lookup-before-retry for ambiguous writes, sends exactly one post-or-void outcome with all original fields (full post/void may use the original amount or amount_max), runs a shared-safe ledger query, and destroys the client in finally. The adapter does not expose gateway persisted, so durable workflows confirm every write with exact post-write lookup.

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

const LEDGER = 840;
const AUTHORIZATION_TIMEOUT_SECONDS = 300;

const IDS = {
  omnibus: 91000000000000000001n,
  customer: 91000000000000000002n,
  settlement: 91000000000000000003n,
  suspense: 91000000000000000004n,
  funding: 92000000000000000001n,
  authorization: 92000000000000000002n,
  post: 92000000000000000003n,
  void: 92000000000000000004n,
} as const;

class CreateRejectedError extends Error {}

function account(id: bigint, code: number, constrained: boolean): 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: LEDGER,
    code,
    flags: AccountFlags.history | (constrained ? AccountFlags.debits_must_not_exceed_credits : AccountFlags.none),
    timestamp: 0n,
  };
}

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

function assertCreateSucceeded(
  operation: 'createAccounts' | 'createTransfers',
  results: CreateAccountResult[] | CreateTransferResult[],
): void {
  if (results.length === 0) return;
  const capacityDecline = results.some(
    (item) => item.result === CreateTransferError.exceeds_credits,
  );
  throw new CreateRejectedError(
    `${operation} rejected${capacityDecline ? ' (capacity)' : ''}: ${JSON.stringify(results)}`,
  );
}

function statusOf(error: unknown): number | undefined {
  if (!error || typeof error !== 'object' || !('status' in error)) return undefined;
  return typeof error.status === 'number' ? error.status : undefined;
}

function isAmbiguousWrite(error: unknown): boolean {
  if (error instanceof CreateRejectedError) return false;
  const status = statusOf(error);
  return status === undefined || status >= 500;
}

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 assertSameTransfer(actual: Transfer, intended: Transfer): void {
  const matches =
    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;

  if (!matches) throw new Error(`Transfer ID ${intended.id} belongs to another event`);
}

async function accountsAlreadyExist(client: Client, intended: Account[]): Promise<boolean> {
  const found = await client.lookupAccounts(intended.map((item) => item.id));
  if (found.length === 0) return false;

  const intendedById = new Map(intended.map((item) => [item.id, item]));
  if (
    found.length !== intended.length ||
    !found.every((item) => {
      const expected = intendedById.get(item.id);
      return expected !== undefined && sameAccount(item, expected);
    })
  ) {
    throw new Error('Neobank account IDs are only partially present or belong to different accounts');
  }

  return true;
}

async function ensureAccounts(client: Client, intended: Account[]): Promise<void> {
  if (await accountsAlreadyExist(client, intended)) return;

  let results: CreateAccountResult[] | CreateTransferResult[];
  try {
    results = await client.createAccounts(intended);
  } catch (error) {
    if (!isAmbiguousWrite(error)) throw error;
    if (await accountsAlreadyExist(client, intended)) return;
    throw error;
  }

  if (await accountsAlreadyExist(client, intended)) return;
  if (results.length === 0) {
    throw new Error('Account write was not confirmed by lookup; do not advance or change the payload');
  }
  assertCreateSucceeded('createAccounts', results);
}

async function transferAlreadyExists(client: Client, intended: Transfer): Promise<boolean> {
  const [found] = await client.lookupTransfers([intended.id]);
  if (!found) return false;
  assertSameTransfer(found, intended);
  return true;
}

async function createTransferWithOneAmbiguousRetry(client: Client, intended: Transfer): Promise<void> {
  // Every durable retry starts with lookup, including recovery after a process
  // stopped after commit but before it persisted the response.
  if (await transferAlreadyExists(client, intended)) return;

  for (let attempt = 0; attempt < 2; attempt += 1) {
    let results: CreateAccountResult[] | CreateTransferResult[];
    try {
      results = await client.createTransfers([intended]);
    } catch (error) {
      if (!isAmbiguousWrite(error)) throw error;

      if (await transferAlreadyExists(client, intended)) return;

      if (attempt === 1) throw error;
      // Retry only the identical object with the identical, persisted ID.
      continue;
    }

    if (await transferAlreadyExists(client, intended)) return;
    if (results.length === 0) {
      throw new Error('Transfer write was not confirmed by lookup; do not advance or change the payload');
    }
    assertCreateSucceeded('createTransfers', results);
  }
}

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

function paymentOutcome(): 'post' | 'void' {
  const value = mustGetEnv('PAYMENT_OUTCOME');
  if (value === 'post' || value === 'void') return value;
  throw new Error('PAYMENT_OUTCOME must be post or void');
}

async function main(): Promise<void> {
  const client = createClient({
    baseUrl: mustGetEnv('PARIX_BASE_URL'),
    apiKey: mustGetEnv('PARIX_API_KEY'),
    databaseId: mustGetEnv('PARIX_DATABASE_ID'),
  });

  try {
    const accounts = [
      account(IDS.omnibus, 210, false),
      account(IDS.customer, 200, true),
      account(IDS.settlement, 220, false),
      account(IDS.suspense, 230, false),
    ];
    await ensureAccounts(client, accounts);

    const funding = transfer({
      id: IDS.funding,
      debitAccountId: IDS.omnibus,
      creditAccountId: IDS.customer,
      amount: 10000n,
      code: 2000,
      flags: TransferFlags.none,
    });
    await createTransferWithOneAmbiguousRetry(client, funding);

    const authorization = transfer({
      id: IDS.authorization,
      debitAccountId: IDS.customer,
      creditAccountId: IDS.settlement,
      amount: 2500n,
      timeout: AUTHORIZATION_TIMEOUT_SECONDS,
      code: 2010,
      flags: TransferFlags.pending,
    });
    await createTransferWithOneAmbiguousRetry(client, authorization);

    const outcome = paymentOutcome();
    // Full post/void uses the original reserved amount (2500). For partial
    // presentment, post a smaller amount. amount_max posts/voids full remaining.
    const terminalAmount = authorization.amount;
    // Alternatives: presentmentAmount for partial post; amount_max for full remaining.
    const terminalTransfer = transfer({
      id: outcome === 'post' ? IDS.post : IDS.void,
      // Repeat every original external field; do not zero-fill these on Shared.
      debitAccountId: authorization.debit_account_id,
      creditAccountId: authorization.credit_account_id,
      amount: terminalAmount,
      pendingId: authorization.id,
      timeout: 0,
      code: authorization.code,
      flags: outcome === 'post' ? TransferFlags.post_pending_transfer : TransferFlags.void_pending_transfer,
    });
    await createTransferWithOneAmbiguousRetry(client, terminalTransfer);

    const paymentEvents = await client.queryTransfers({
      user_data_128: 0n,
      user_data_64: 0n,
      user_data_32: 0,
      ledger: LEDGER,
      code: 2010,
      timestamp_min: 0n,
      timestamp_max: 0n,
      limit: 100,
      flags: QueryFilterFlags.none,
    });

    console.log({ outcome, paymentEvents });
  } finally {
    client.destroy();
  }
}

void main();

Full post and void in this sample use the original reserved amount. The package also exports amount_max for posting or voiding the full remaining pending amount; use a smaller presentment amount for partial capture.

In production, provision accounts outside the payment request, take the terminal outcome from a verified and deduplicated processor event, and persist every event ID before calling Parix. The fixed IDs only make the example's retry and lifecycle relationships inspectable.

Failure and retry handling

Successful creates return an empty result array. TigerBeetle create conflicts return indexed results; the Node adapter returns those results from createAccounts() and createTransfers() rather than throwing. Never treat a nonempty array as success by itself: look up every stable ID and accept only complete, exact matches from a prior committed attempt; otherwise reject or reconcile the batch.

SignalInterpretationAction
Empty create result []Adapter reported no item conflictsStill look up every stable ID and exact fields before advancing. The adapter does not expose gateway persisted.
Nonempty create resultIndexed TigerBeetle rejectionMap indexes to inputs and classify result (for example CreateTransferError.exceeds_credits). HTTP 409 + tbResults is unwrapped into this array. Look up every submitted ID.
Pending already posted, voided, expired, or not foundProcessor event conflicts with ledger lifecycleStop automatic retries. Compare authorization ID, verified processor state, and ledger history; route the discrepancy to operations.
Pending has different account, ledger, or code, or exceeds its amountTerminal record does not match the authorization or attempts to post more than the pending amountFix the mapping bug. Enforce the application's positive-post rule before calling Parix. Shared callers must send the original external fields explicitly.
HTTP 400Strict payload, path, field, or batch validation failedCorrect the request; do not retry unchanged.
HTTP 401 or 403Credential, scope, database boundary, shared-ledger, timeout, or plan policy blocked the callCorrect access or policy. Do not use a human CLI OAuth session as service authentication.
HTTP 402Developer billing state blocks the operationRestore billing and re-evaluate the same persisted event.
HTTP 429Quota, rate limit, or shared-cell admission limitSeparate hard quota exhaustion from transient pressure. Back off only when appropriate and preserve every ID.
HTTP 503Deployment warming, shared placement, or gateway availability problemUse bounded backoff for warming; escalate persistent placement failure. Lookup write IDs before resubmission.
HTTP 500, connection reset, or client timeoutThe write may or may not have committedLookup the stable ID. Accept an exact match; retry an absent event only with the identical ID and payload.
Processor says approved but ledger post is absentDistributed workflow or delivery failureRe-drive the verified event with its stable post ID after lookup. Keep customer-visible state conservative until reconciled.
Ledger and sponsor-bank totals differReconciliation breakOpen a case, preserve evidence, and use governed suspense transfers. Never silently edit history.

There is no request-level idempotency key on TigerBeetle operations. Stable account and transfer IDs are the idempotency boundary. Retain original batch ordering while interpreting results. Only one deliberately linked chain inside one create_transfers request is atomic; normal batch neighbors, later lifecycle calls, and provider operations are independent.

The current gateway can return only the first ten create conflicts. An item omitted from tbResults in a failed larger batch is not proven successful; look up its stable ID or reduce batch size until every outcome is confirmed.

Test scenarios

Exercise these cases in an isolated non-production database with synthetic identities and processor events:

ScenarioSetup and actionExpected result
Account provisioningCreate customer, omnibus, settlement, and suspense roles with the documented flagsEmpty create result; lookups return four accounts on ledger 840 and no PII appears in records.
Customer fundingPost 10,000 cents from omnibus to customerCustomer posted credits rise by 10,000 and code 2000 appears once.
AuthorizationCreate a 2,500-cent pending customer-to-settlement transfer with timeout 300Customer pending debits and settlement pending credits rise; available customer funds fall without changing posted totals.
PostSubmit one post record with a new ID, the pending ID, and all original fieldsPending totals release and posted debit/credit totals rise by 2,500.
Partial presentmentAuthorize 2,500 cents, then post 2,000 with the original accounts, ledger, code, and pending IDPosted totals rise by 2,000; the remaining 500 of pending value is released and cannot be posted again.
Zero-amount post policyAttempt a zero-amount post through the payment applicationThe application rejects it before submission. TigerBeetle 0.17.6 would release the pending amount without posting value.
VoidIn a fresh authorization, submit the void alternative with all original fieldsPending totals release; posted totals do not move.
Natural expirationAllow a short, test-only pending timeout to expire without post or voidPending balances release according to TigerBeetle timeout seconds; the application still records and reconciles processor state.
Delayed presentmentLet an authorization expire, then receive a processor presentmentPosting the expired pending ID is rejected; the event is routed through the reviewed late-presentment and reconciliation policy.
Return after postingAfter a full or partial post, deliver a verified reversal or return with a new code-2011 IDA new settlement-to-customer compensating transfer restores the approved amount; the posted history remains immutable.
Duplicate processor deliveryDeliver the same authorization or terminal event twiceStable ID prevents a second movement; lookup proves the existing event matches.
Post/void raceConcurrently deliver verified post and cancellation events for one pending IDOnly one terminal lifecycle succeeds. The other is a deterministic conflict routed to reconciliation.
Mismatched terminal fieldsChange an original account, ledger, or code, or post more than the authorized amountCreate is rejected; no automatic replacement event is generated.
Customer no-overdraftAuthorize more than remaining customer funds, including concurrent attemptsConstraint rejects unaffordable authorization and the ledger never exposes a negative available balance under this convention.
Ambiguous writeDrop the HTTP response after authorization or post, then perform lookup-before-retryExact committed event is accepted once; absent event is retried only with its original ID and body.
Shared namespace pathRun post and void alternatives against separate fresh pending transfers on Shared, using explicit original external fieldsLifecycle mapping succeeds for each valid terminal event; missing ledger on a shared query is rejected.
Reconciliation breakInject a processor settlement difference and move it through suspenseDifference is visible, assigned, aged, approved, and resolved by immutable transfers.
PII boundaryScan IDs, user-data fields, payload logs, and error logsOnly opaque references appear; regulated personal and payment data remains in approved systems.

Production operations

  • Use only Production or Enterprise for production value movement. Developer is shared and quota-limited; Dedicated Single Node is isolated but non-production and non-HA.
  • Keep licensing, sponsor-bank, KYC, AML, sanctions, fraud, transaction-monitoring, disputes, complaints, and reporting controls outside Parix and under the accountable regulated program.
  • Bind API keys to the required database where possible, store them in server-side secret management, rotate them, and audit their use. Reserve CLI OAuth for authorized human operators.
  • Govern ledgers, codes, flags, timeout seconds, account roles, and stable-ID derivation as versioned configuration reviewed by engineering, finance, compliance, and operations.
  • Deduplicate verified processor messages in durable storage. Use an inbox/outbox or workflow engine so authorization, post, void, reversal, return, and settlement work survives crashes and replay.
  • Reconcile at least customer control totals, omnibus cash, processor settlement, sponsor-bank statements, and open pending transfers. Reconciliation compares external truth; it is not replaced by balancing only the TigerBeetle accounts.
  • Give every suspense item an external case ID, owner, reason, approval trail, and aging target. Alert on unexpected balance, age, and volume; target a cleared suspense position.
  • Monitor request status, database health, latency, pending count and age, no-overdraft declines, reconciliation breaks, and plan quota/rate pressure. Separate expected business rejections from platform incidents.
  • Validate pending timeouts against the current plan limit and processor rules. An expired ledger authorization does not cancel or reverse the provider-side authorization by itself.
  • Exercise backup and recovery on the chosen production plan and provider. Recovery procedures must also explain how processor events received during an outage are replayed safely by stable ID.
  • Keep public batches at or below 8190 items and within lower plan-specific limits. Preserve each input's ordering and correlation until all conflict results are resolved.
  • Use linked chains only for multi-leg ledger events that must commit together in one call, setting linked on every event except the chain's last. Never claim atomicity across calls or external systems.
  • Preserve immutable, non-sensitive correlation identifiers in structured logs. Keep PII, KYC data, account numbers, card data, and raw provider payloads out of TigerBeetle and general operational logs.