Skip to main content
PARIXDocs

Marketplaces

Implement seller holds, atomic order allocation, fees, reserves, refunds, chargebacks, and payouts on Parix.

Overview

Use Parix as the value ledger behind a marketplace when one captured payment must fund seller proceeds, platform fees, reserves, refunds, chargebacks, and payouts without being duplicated by retries.

This guide uses integer cents in ledger 7001. One order for 10000 cents is captured into buyer clearing and then allocated atomically as:

AllocationAmountDestination
Seller proceeds8500Seller pending
Platform fee1000Platform fee
Reserve500Reserve

The three allocation transfers form one linked TigerBeetle chain. If any leg fails, none of the three legs is committed.

Architecture and ownership

Parix is the system of record for value movement. It is not the order, payment, identity, tax, or bank-payout system.

ComponentOwnsDoes not own
Marketplace applicationOrders, seller eligibility, release policy, fee calculation, stable ID mapping, sagasAuthoritative posted ledger balances
ParixAccount and transfer records, balance constraints, linked-batch atomicityOrder status, delivery evidence, refund policy, seller identity
Payment processorAuthorization, capture, refund, dispute, and processor-settlement stateSeller subledger allocation
Payout provider or bankExternal payout instruction and bank-settlement statusSeller available balance
Reporting and reconciliationCross-system matching, break investigation, reviewed adjustmentsSilent mutation or deletion of previously committed ledger history

Use a durable outbox or workflow to move between these systems. A Parix commit and an external processor or bank call are not one distributed transaction.

Ledger model

Ledgers

LedgerUnitPurpose
7001USD centsOrder capture, seller balances, platform fees, reserves, refunds, chargebacks, and payouts

Use a different ledger for each currency or non-fungible unit. Never transfer between accounts with different ledgers. Currency conversion is an application-owned business event with separately priced legs.

Accounts

The example treats seller balances as credit-normal: available value is credits_posted - debits_posted. history is flag 8; debits_must_not_exceed_credits is flag 2; together they are 10.

RoleExample IDCodeFlagsBalance meaning and ownership
Processor settlement7100000000000000011008External capture/settlement source; reconcile to processor reports
Buyer clearing71000000000000000210110Captured amount waiting for allocation; should return to zero per order
Seller pending71000000000000000320110Posted seller proceeds not yet eligible for payout
Seller available71000000000000000420210Posted seller proceeds eligible for payout
Platform fee71000000000000000530110Platform fee position; cannot fund refunds beyond posted fees
Reserve71000000000000000630210Amount retained under marketplace reserve policy; no silent overdraft
Payout clearing71000000000000000740110Ledger-approved payouts waiting for external bank settlement
Refund/chargeback clearing71000000000000000840210Outbound refunds and disputes awaiting processor reconciliation

In a real marketplace, create one pending and one available account for each seller and currency. Store seller identity and the account-ID mapping in the application database; do not put personally identifiable information in TigerBeetle IDs or user-data fields.

Transfer codes

CodeEventDebitCredit
100Processor captureProcessor settlementBuyer clearing
110Order seller allocationBuyer clearingSeller pending
111Order platform feeBuyer clearingPlatform fee
112Order reserveBuyer clearingReserve
120Release seller proceedsSeller pendingSeller available
130Initiate payoutSeller availablePayout clearing
140RefundSeller pending/available, fee, or reserveRefund clearing
141ChargebackSeller available or reserveRefund/chargeback clearing
150Reviewed reconciliationAccount with excess positionAccount with deficient position

Keep capture, allocation, release, payout, refund, chargeback, and reviewed adjustment codes distinct even when they move value between the same accounts.

Invariants

InvariantEnforcement
An order allocation conserves captured valueRequire seller proceeds + fee + reserve = captured amount before submission
An allocation is all-or-nothingSend every allocation leg in one batch; set linked on every non-final leg and never on the final leg
Seller pending and available cannot overspendApply debits_must_not_exceed_credits to both seller accounts
Fee, reserve, and clearing cannot overspendApply the same no-overdraft flag to platform fee, reserve, payout clearing, and refund clearing
A business event is applied oncePersist one stable transfer ID per event/leg before the first request and reuse it for lookup and retry
History remains explainableRefunds, chargebacks, payout returns, and reconciliation fixes are new compensating transfers
Clearing accounts convergeReconcile buyer, refund/chargeback, and payout clearing against order, processor, and bank records
Currency does not cross a ledger boundaryGive every account and transfer in one currency the same ledger

Seller pending account versus a pending transfer

Seller pending in this model is an ordinary account containing posted credits. It represents a marketplace release policy: the seller owns proceeds, but the application has not made them payout-eligible.

A TigerBeetle pending transfer is different. It uses TransferFlags.pending, affects pending debit/credit fields, has a timeout, and must later be posted or voided with another transfer that references pending_id. Use that mechanism for a genuine two-phase event such as payment authorization. Do not mark the allocation transfer pending merely because its destination account is named seller pending.

Lifecycle movements

EventLedger action
ReleaseDebit seller pending and credit seller available with one stable release ID
PayoutDebit seller available and credit payout clearing before enqueueing the external payout; compensate a definitive payout failure
RefundReverse the original seller, fee, and reserve allocation as policy requires, using a linked compensating batch
ChargebackDebit seller available and/or reserve into chargeback clearing; route any shortfall to a reviewed receivable policy, not silent overdraft
ReconcileMatch order allocations and clearing positions to processor/bank records; post only approved, uniquely identified adjustment transfers

For a partial refund, calculate each compensating leg with a documented rounding rule and ensure the legs sum to the refund amount. Never delete or rewrite the original allocation.

Before you begin

You need:

  • a Parix database in Ready state and its immutable database UUID;
  • an integer unit, ledger IDs, account codes, transfer codes, and balance convention approved by engineering and finance;
  • a durable mapping from each order, capture, allocation leg, release, refund, chargeback, and payout to a stable 128-bit transfer ID;
  • an OAuth session for CLI work and a specific-database API key for server-side application traffic; and
  • a reconciliation owner and procedures for processor, bank, and ledger breaks.

Choose the plan for the workload, not just for the dashboard features:

PlanWorkload posture
DeveloperLearning, prototypes, and integration tests only; shared and quota-limited
Dedicated Single NodeIsolated development, staging, or non-HA work; not a production plan
Production HA/Production 6Production workloads requiring a supported production topology
EnterpriseContract-defined production topology, networking, compliance, or support

All plans use the Parix HTTPS gateway. Do not configure replica addresses or a native TigerBeetle protocol connection.

The public schema accepts at most 8,190 accounts, transfers, or lookup IDs per array request. The active plan can impose a lower events-per-request limit, so read the selected database dashboard and batch below the lower limit.

On a shared Developer database, query_accounts and query_transfers require a ledger filter. Use ledger: 7001 in API/SDK filters or --ledger 7001 in the CLI. A dedicated database may omit the ledger when an intentionally unscoped query is appropriate.

Dashboard walkthrough

  1. Select the organization, open the database, and confirm Ready, the intended plan, and the database UUID.
  2. Review effective quotas and events-per-request on the dashboard.
  3. Select Connect. Generate a Specific database API key for the marketplace service and store the one-time secret in a secret manager.
  4. Open Query, select Query accounts, set ledger 7001, and run a ledger-scoped smoke query. On Shared, the first query for an unseen external ledger can allocate its project-ledger mapping and consume quota, so use the planned test ledger rather than treating the query as side-effect-free.
  5. Switch to Create accounts only in the intended development or test database and review every ID, ledger, code, and flag before selecting Run.

Parix Query create-accounts form showing account fields and the Run action

The create-accounts form is a live write surface for the selected database. It does not show an order-specific marketplace model and it is not a dry run.

Live-write warning: Query can execute create_accounts and create_transfers. Those operations write immediately to the selected database. Generated IDs are conveniences for manual testing, not your production idempotency strategy. Use a non-production database and verify the selected database, stable IDs, ledger, codes, flags, and amounts before running a write.

CLI walkthrough

The commands below use the latest published @parix/cli package. The CLI is an operator/developer tool: it uses browser OAuth and the active organization. Production services must use a server-side API key, not the CLI session file.

Install the exact version globally, sign in to the intended environment, and record the database UUID:

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"

parix --version should report the installed package version. The CLI defaults to https://parix.io. Use --base-url <other-origin> only when intentionally targeting another deployment; sessions and database IDs are not interchangeable across environments.

Create marketplace-accounts.json. The request body is a bare JSON array, not { "accounts": [...] }.

[
  { "id": "710000000000000001", "ledger": 7001, "code": 100, "flags": 8 },
  { "id": "710000000000000002", "ledger": 7001, "code": 101, "flags": 10 },
  { "id": "710000000000000003", "ledger": 7001, "code": 201, "flags": 10 },
  { "id": "710000000000000004", "ledger": 7001, "code": 202, "flags": 10 },
  { "id": "710000000000000005", "ledger": 7001, "code": 301, "flags": 10 },
  { "id": "710000000000000006", "ledger": 7001, "code": 302, "flags": 10 },
  { "id": "710000000000000007", "ledger": 7001, "code": 401, "flags": 10 },
  { "id": "710000000000000008", "ledger": 7001, "code": 402, "flags": 10 }
]

Submit and inspect the result:

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

For this example, record the processor capture into buyer clearing:

parix tb create-transfers "$PARIX_DATABASE_ID" \
  --id 720000000000000001 \
  --from 710000000000000001 \
  --to 710000000000000002 \
  --amount 10000 \
  --ledger 7001 \
  --code 100 \
  --json

Create marketplace-order-split.json. Every non-final leg has flag 1 (linked); the final leg has flag 0. Reordering the array changes which leg must omit linked.

[
  {
    "id": "720000000000000002",
    "debit_account_id": "710000000000000002",
    "credit_account_id": "710000000000000003",
    "amount": "8500",
    "ledger": 7001,
    "code": 110,
    "flags": 1
  },
  {
    "id": "720000000000000003",
    "debit_account_id": "710000000000000002",
    "credit_account_id": "710000000000000005",
    "amount": "1000",
    "ledger": 7001,
    "code": 111,
    "flags": 1
  },
  {
    "id": "720000000000000004",
    "debit_account_id": "710000000000000002",
    "credit_account_id": "710000000000000006",
    "amount": "500",
    "ledger": 7001,
    "code": 112,
    "flags": 0
  }
]

Submit the atomic allocation, verify the stable transfer IDs, and query the ledger:

parix tb create-transfers "$PARIX_DATABASE_ID" --file ./marketplace-order-split.json --json
parix tb lookup-transfers "$PARIX_DATABASE_ID" --ids 720000000000000002,720000000000000003,720000000000000004 --json
parix tb query-accounts "$PARIX_DATABASE_ID" --ledger 7001 --limit 20 --json

A successful create response has persisted: true and an empty responsePayload ([]). An empty create-result array means every item succeeded. HTTP 200 with persisted: false is not a committed write. A non-empty create-result array identifies rejected items by array index and numeric TigerBeetle result code; through the public HTTP route that conflict is returned as HTTP 409 with tbResults. An unlinked batch can have successful and rejected items, while a correctly linked chain commits or rejects as a unit. Public conflict details are currently capped at the first 10 results, so reconcile every submitted stable ID; an unlisted item must not be assumed successful or failed.

Release proceeds only after the marketplace release condition is durable:

parix tb create-transfers "$PARIX_DATABASE_ID" \
  --id 720000000000000005 \
  --from 710000000000000003 \
  --to 710000000000000004 \
  --amount 8500 \
  --ledger 7001 \
  --code 120 \
  --json

For a partial refund while seller proceeds still sit in seller available, return 1000 cents from seller available, 200 from platform fee, and 100 from reserve into refund clearing. Save marketplace-partial-refund.json. Every non-final leg is linked.

Do this before a full payout. Constrained accounts (flags: 10) reject a debit that would overspend posted credits, so a refund that tries to pull 1000 from seller available after you have already paid out the full 8500 fails with exceeds_credits and the linked chain aborts.

[
  {
    "id": "720000000000000007",
    "debit_account_id": "710000000000000004",
    "credit_account_id": "710000000000000008",
    "amount": "1000",
    "ledger": 7001,
    "code": 140,
    "flags": 1
  },
  {
    "id": "720000000000000008",
    "debit_account_id": "710000000000000005",
    "credit_account_id": "710000000000000008",
    "amount": "200",
    "ledger": 7001,
    "code": 140,
    "flags": 1
  },
  {
    "id": "720000000000000009",
    "debit_account_id": "710000000000000006",
    "credit_account_id": "710000000000000008",
    "amount": "100",
    "ledger": 7001,
    "code": 140,
    "flags": 0
  }
]
parix tb create-transfers "$PARIX_DATABASE_ID" --file ./marketplace-partial-refund.json --json
parix tb lookup-transfers "$PARIX_DATABASE_ID" --ids 720000000000000007,720000000000000008,720000000000000009 --json

After the refund, seller available holds 7500 cents (8500 − 1000). Initiate payout for that remaining balance only after the release is durable, the refund is durable, and the external payout instruction is ready:

parix tb create-transfers "$PARIX_DATABASE_ID" \
  --id 720000000000000006 \
  --from 710000000000000004 \
  --to 710000000000000007 \
  --amount 7500 \
  --ledger 7001 \
  --code 130 \
  --json

The constrained fee and reserve accounts also reject a refund leg that would overspend their posted balances; treat that create conflict as a business decline and open a reviewed receivable path rather than weakening the flags. If the product must refund after a full payout, fund the seller leg from payout clearing (or another funded position), not from an empty seller available account.

The numeric IDs above are stable sample values. Allocate and persist unique IDs for your own business events; never reuse these values for unrelated events or generate a new ID merely because a request timed out. These CLI steps leave user_data_* at zero; set a non-zero correlation only when you will query by order or claim ID. See Optional user_data fields.

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 accepts { baseUrl, apiKey, databaseId }, converts bigint fields to decimal strings on the wire, and converts bigint response fields back to bigint. It does not expose the raw gateway envelope field persisted, so durable workflows require exact post-write lookup of every stable ID before advancing. Use full TigerBeetle objects in application code even though the HTTP schema permits some zero-valued fields to be omitted.

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 = 7001;
const accountId = {
  processorSettlement: 710000000000000001n,
  buyerClearing: 710000000000000002n,
  sellerPending: 710000000000000003n,
  sellerAvailable: 710000000000000004n,
  platformFee: 710000000000000005n,
  reserve: 710000000000000006n,
  payoutClearing: 710000000000000007n,
  refundClearing: 710000000000000008n,
} as const;

// Persist these IDs with the order before the first write. Never allocate IDs inside a retry loop.
const transferId = {
  capture: 720000000000000001n,
  sellerAllocation: 720000000000000002n,
  feeAllocation: 720000000000000003n,
  reserveAllocation: 720000000000000004n,
} as const;

function account(id: bigint, 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,
    code,
    flags,
    timestamp: 0n,
  };
}

function transfer(input: {
  id: bigint;
  debitAccountId: bigint;
  creditAccountId: bigint;
  amount: bigint;
  code: number;
  flags?: 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: 0n,
    user_data_64: 0n,
    user_data_32: 0,
    timeout: 0,
    ledger,
    code: input.code,
    flags: input.flags ?? TransferFlags.none,
    timestamp: 0n,
  };
}

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

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 hasSameImmutableAccountFields(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 hasSameImmutableFields(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((item) => item.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 && hasSameImmutableAccountFields(item, intended);
    })
  ) {
    throw new Error('Account IDs are only partially present or exist with different immutable fields');
  }

  return true;
}

async function transfersExistExactly(label: string, batch: Transfer[]): Promise<boolean> {
  const found = await client.lookupTransfers(batch.map((item) => item.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 && hasSameImmutableFields(item, intended);
    })
  ) {
    throw new Error(`${label} IDs are only partially present or exist with different immutable fields`);
  }

  return true;
}

async function createAccountsOrResolveAmbiguity(batch: Account[]): Promise<void> {
  // This lookup makes a new process safe after an earlier process committed but
  // stopped before persisting the HTTP response.
  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('Accounts were not found; retry only with the same IDs and payload', { 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');
  }
  assertEmptyResults('createAccounts', results);
}

async function createTransfersOrResolveAmbiguity(label: string, batch: Transfer[]): Promise<void> {
  // Stable-ID lookup is also the first step after a worker or process restart.
  if (await transfersExistExactly(label, batch)) return;

  let results: CreateAccountResult[] | CreateTransferResult[];

  try {
    results = await client.createTransfers(batch);
  } catch (cause) {
    if (wasDefinitelyRejected(cause)) throw cause;

    // A timeout, network failure, 500, or 503 can occur after commit. Lookup before retrying.
    if (await transfersExistExactly(label, batch)) return;
    throw new Error(`${label} was not found; retry only with the same IDs and payload`, { 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`);
  }
  assertEmptyResults(label, results);
}

async function main(): Promise<void> {
  const constrainedHistory = AccountFlags.history | AccountFlags.debits_must_not_exceed_credits;
  const accounts = [
    account(accountId.processorSettlement, 100, AccountFlags.history),
    account(accountId.buyerClearing, 101, constrainedHistory),
    account(accountId.sellerPending, 201, constrainedHistory),
    account(accountId.sellerAvailable, 202, constrainedHistory),
    account(accountId.platformFee, 301, constrainedHistory),
    account(accountId.reserve, 302, constrainedHistory),
    account(accountId.payoutClearing, 401, constrainedHistory),
    account(accountId.refundClearing, 402, constrainedHistory),
  ];

  await createAccountsOrResolveAmbiguity(accounts);

  await createTransfersOrResolveAmbiguity('capture', [
    transfer({
      id: transferId.capture,
      debitAccountId: accountId.processorSettlement,
      creditAccountId: accountId.buyerClearing,
      amount: 10000n,
      code: 100,
    }),
  ]);

  const allocation = [
    transfer({
      id: transferId.sellerAllocation,
      debitAccountId: accountId.buyerClearing,
      creditAccountId: accountId.sellerPending,
      amount: 8500n,
      code: 110,
      flags: TransferFlags.linked,
    }),
    transfer({
      id: transferId.feeAllocation,
      debitAccountId: accountId.buyerClearing,
      creditAccountId: accountId.platformFee,
      amount: 1000n,
      code: 111,
      flags: TransferFlags.linked,
    }),
    transfer({
      id: transferId.reserveAllocation,
      debitAccountId: accountId.buyerClearing,
      creditAccountId: accountId.reserve,
      amount: 500n,
      code: 112,
      flags: TransferFlags.none,
    }),
  ];

  await createTransfersOrResolveAmbiguity('order allocation', allocation);
}

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();
}

For retries, persist the exact payload as well as its IDs. If lookup finds an existing ID, compare its immutable fields with the intended event before declaring success. An ID that exists with different fields is a reconciliation incident, not an idempotent success.

Failure and retry handling

SignalMeaningAction
Empty create result []Adapter reported no item conflictsStill look up every stable ID and exact immutable fields before advancing. The adapter does not expose gateway persisted.
Nonempty create resultIndexed items were rejected; unlinked items may still have committedReconcile every submitted ID. The adapter unwraps HTTP 409 + tbResults into this array; only the first 10 conflict details may be returned.
Thrown HTTP error without item resultsAuth, schema, plan, bare 409, or transport failureClassify by status. For ambiguous outcomes (5xx, timeout, disconnect), look up before retrying identical IDs.
HTTP 400Invalid strict payload or unsupported valueCorrect the payload; do not retry unchanged
HTTP 401 or 403Invalid credential, scope, organization, or databaseFix credentials or resource scope; do not retry in a loop
HTTP 402Developer billing state blocks the operationRestore the subscription/billing state; do not retry unchanged
HTTP 404Database, profile, route, or visible resource is absentVerify environment and database UUID; do not retry blindly
HTTP 429Rate/admission saturation or a durable plan quotaBack off with jitter only for transient rate/admission limits; for quota exhaustion, wait for reset or change the plan/workload
Timeout, disconnect, HTTP 500 or 503Outcome may be ambiguousLookup every stable transfer ID; accept exact matches, investigate partial visibility, retry same IDs only when absent
Insufficient seller balance resultRelease, refund, chargeback, or payout violates constraintStop the business action; apply reserve/receivable policy through reviewed transfers
External payout definitively failedLedger payout clearing committed but bank did notSubmit a uniquely identified compensating transfer from payout clearing back to seller available

Linked does not mean “continue after an error.” A linked-chain failure rejects the chain. Treat any unexpected observation of only part of a linked business operation as an incident and halt automated compensation until reconciled.

Test scenarios

Run these scenarios against a dedicated non-production database with the same schema and limits expected in production.

ScenarioSetup/actionExpected result
Happy-path capture and splitCapture 10000; allocate 8500 + 1000 + 500Empty result arrays; buyer clearing returns to zero; three destinations credited
Duplicate deliverySubmit the exact same split IDs and payload againConflict/existing results; no second allocation
Same ID, changed amountResubmit one transfer ID with a different amountConflict; reconciliation alert; never accepted as success
Linked middle-leg failureUse an invalid fee account in the three-leg chainNo allocation leg commits
Open linked chainSet linked on the final legChain rejected; no allocation leg commits
Release before eligibilityAsk application workflow to release an ineligible orderApplication rejects before a ledger write
Double releaseDeliver the release event twice with the same stable IDOnly the original release exists
Refund before releaseReverse seller pending, fee, and reserve in a linked batchRefund clearing receives exact refund; available is unchanged
Refund after releaseReverse available and fee/reserve legs before full payoutExact compensating history; seller available reduced; remaining balance can pay out
Refund after full payoutDebit empty seller available after paying out all proceedsLinked chain rejected (exceeds_credits); fund seller leg from payout clearing instead
Chargeback after payoutApply seller/reserve recovery after external disputeChargeback clearing matches processor event; any shortfall is explicit
Ambiguous write responseDrop the client connection after sending a batchLookup determines committed/absent before any same-ID retry
Payout provider failureCommit payout clearing, then return a definitive provider failureUnique compensation restores seller available
Shared query without ledgerQuery a Developer database without ledgerRequest rejected; adding ledger 7001 succeeds
Plan batch boundaryTest at effective plan limit and one item above itAt-limit request is handled; above-limit request is rejected before business retry

Production operations

  • Use Production HA, Production 6, or a contract-defined Enterprise plan for production. Developer and Dedicated Single Node are non-production plans.
  • Keep the API key in server-side secret storage, scope it to the marketplace database, rotate it, and never expose it to browser or mobile clients.
  • Persist business-event IDs and exact intended payloads before submission. Include IDs—not secrets or sensitive full payloads—in structured logs.
  • Serialize state-machine transitions per order or make every transition compare-and-set safe. Ledger idempotency does not prevent an application from choosing the wrong next business event.
  • Reconcile buyer clearing per order, processor settlement per settlement period, payout clearing per provider payout, and refund/chargeback clearing per processor event.
  • Alert on non-empty result arrays, ambiguous outcomes, partial linked-batch observations, stale clearing balances, overdue seller-pending balances, and payout/refund mismatches.
  • Keep reviewed adjustment permissions separate from normal order processing. Adjustments use dedicated codes, approvals, and immutable business references.
  • Batch below both the 8,190 schema maximum and the active plan limit. Preserve linked chains within one request; never split one chain across HTTP requests.
  • Document backup, restore, recovery, region, support, and reconciliation objectives for the selected production plan.