Wallets
Build customer and merchant wallet balances, top-ups, purchases, refunds, and history on Parix.
Overview
This guide implements a single-currency wallet ledger with three account roles:
- a platform reserve that funds customer top-ups;
- customer wallets that cannot spend more than their posted credits; and
- merchant wallets that receive purchases and fund refunds.
The examples use ledger 840 for USD and store amounts in cents. Replace the ledger, codes, and IDs with values from your own governed registry. Do not reuse the example IDs in more than one database.
Parix provides the managed TigerBeetle ledger and gateway. It does not move money through a card processor or bank, authenticate customers, or replace your settlement and general-ledger systems.
Architecture and ownership
A wallet write crosses several systems. Keep the ownership boundary explicit:
| Component | Owns |
|---|---|
| Wallet application | Authentication, customer and merchant records, limits, business authorization, stable event IDs, statements, disputes, and the customer-facing read model |
| Payment provider or bank | External funding, payout, chargeback, and settlement execution |
| Parix | Authenticated HTTP gateway routing and the managed TigerBeetle database |
| TigerBeetle ledger | Immutable accounts, transfers, pending and posted balances, account constraints, and ordered history |
| Finance operations | Reconciliation, suspense handling, manual-adjustment approval, and general-ledger posting |
Store personal data, card or bank details, and display names outside TigerBeetle. Map them to opaque, stable account IDs in your application database. Persist a transfer ID with the business event before the first write so every retry addresses the same ledger event.
The Parix client path is HTTP through the gateway:
server application -> Parix API -> authenticated private gateway -> managed TigerBeetleApplications do not receive replica addresses and do not connect with the native TigerBeetle protocol.
Ledger model
Ledger
| Ledger | Asset | Unit | Rule |
|---|---|---|---|
840 | USD | cents | Every account and transfer in this example uses ledger 840. Use a different ledger for every separately balanced asset. |
Never transfer directly across ledgers. Currency conversion is an application decision represented by separate, reconciled legs in their respective ledgers.
Account codes
| Code | Role | Balance convention | Flags |
|---|---|---|---|
100 | Platform reserve | Operational funding position; controlled by the application and reconciliation | history (8) |
110 | Customer wallet | Spendable balance is posted credits minus posted debits | history | debits_must_not_exceed_credits (10) |
120 | Merchant wallet | Available merchant balance is posted credits minus posted debits | history | debits_must_not_exceed_credits (10) |
The no-overdraft account flag is the ledger-enforced control. Application balance checks are useful for user experience, but they are not a substitute for this constraint under concurrent writes.
Transfer codes
| Code | Event | Debit account | Credit account | Meaning |
|---|---|---|---|---|
1000 | Top-up | Reserve | Customer | Recognize externally confirmed customer funding |
1010 | Purchase | Customer | Merchant | Move wallet value to a merchant |
1020 | Refund | Merchant | Customer | Return part or all of a prior purchase with a new immutable transfer |
Keep the purchase-to-refund relationship in your application database. A refund gets its own stable transfer ID; it does not edit or reuse the purchase transfer.
Invariants
| Invariant | Enforcement |
|---|---|
| IDs are nonzero, opaque, stable, and unique by object type | Generate or derive the ID in the application and persist it before submission. Never put PII in an ID or user-data field. |
| Amounts are positive integer minor units | Validate currency and amount before constructing the transfer. The examples use cents and never floating point. |
A transfer's two accounts and ledger match | Resolve account metadata before writing; TigerBeetle also rejects mismatches. |
| Customers cannot overdraw | Set debits_must_not_exceed_credits on every customer account. Treat a nonempty create result such as exceeds_credits as a business rejection. |
| A merchant cannot refund more wallet value than it holds | Apply the same debit constraint to merchant accounts, or use a separately governed refund-funding account if your product assumes that liability. |
| History is immutable | Correct a movement with a new compensating transfer. Do not attempt to update an existing transfer. |
| One business event maps to one stable transfer ID | On an ambiguous outcome, look up that ID before submitting the same object again. Never generate a replacement ID for a retry. |
| Atomicity is explicit | Only events in one linked chain are atomic. Put every required leg in one create_transfers batch; set linked (1) on every non-final leg and leave the final leg unlinked (0). Ordinary multi-item batches and separate HTTP calls are not one transaction. |
Before you begin
- 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 before selecting topology, region, quotas, backup posture, or support.
- Create a database and wait until its dashboard status is Ready. Record its database UUID; CLI and API paths use the UUID, not the display name.
- For dashboard and CLI work, sign in as an authorized organization member. For a server application, generate a database-scoped API key and store it only in server-side secret storage.
- Establish a registry for ledgers, account codes, transfer codes, and stable ID derivation. Review it with engineering and finance before the first production write.
- Decide which external event makes a top-up final. Do not credit a customer merely because a client reported success.
- Define reconciliation and compensating-transfer procedures before launch.
@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 }.
Every public create or lookup request is a bare JSON array containing between 1 and 8190 items. Shared Developer quotas can impose a lower effective limit. In raw HTTP JSON, encode IDs, amounts, timestamps, and other bigint-width values as decimal strings to avoid JavaScript precision loss.
Dashboard walkthrough
Live-write warning: The Query explorer runs against the selected live database. Create accounts and Create transfers write immediately and cannot be undone. Use a non-production database, verify the selected organization and database, and record every ID before selecting Run.
- Open the database and confirm Ready, the expected plan, and the database UUID on Dashboard.
- Open Query and select Create accounts.
- Clear Generate random ID when you need a reproducible ID. Enter the reserve ID, ledger
840, code100, and flags bitfield8, then select Run once. - Repeat for the customer and merchant IDs with codes
110and120, setting flags bitfield10on both constrained accounts. - Confirm that each successful create returns zero result rows. A nonempty create-result array is a rejection to investigate, not success.
- Select Create transfers and create the top-up with the recorded reserve and customer IDs, amount in cents, ledger
840, code1000, flags0, and a stable transfer ID. - Create a purchase from customer to merchant with code
1010. Create a refund only as a separate merchant-to-customer transfer with code1020. - Select Get account transfers, enter the customer account ID, include debit and credit history, and review the immutable movement list.
- Select Query accounts or Query transfers for broader inspection. On Shared Developer, always select ledger
840; shared queries require a ledger.

The screenshot shows the single-account Create accounts form. It does not show a completed write or the three wallet accounts; enter and verify each planned account separately.
The dashboard is appropriate for controlled smoke tests and investigation. It is not an application integration surface and its signed-in session is not a production service credential.
CLI walkthrough
The commands in this section use the latest published parix CLI syntax. The CLI is an operator and development surface authenticated by browser OAuth; do not embed its stored OAuth session in a production service.
Install it globally, verify the version, and sign in:
npm install -g @parix/cli@latest
parix --version
parix auth login
parix auth statusparix --version should report the installed package version for these examples. Use the database UUID as the positional <database-id> in every parix tb command.
Create the accounts
Save the following reviewed bare array as wallet-accounts.json. The explicit flags are an advanced field, so a file is clearer and safer than a long flag-driven command.
[
{
"id": "81000000000000000001",
"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": 100,
"flags": 8,
"timestamp": "0"
},
{
"id": "81000000000000000002",
"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": 110,
"flags": 10,
"timestamp": "0"
},
{
"id": "81000000000000000003",
"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": 120,
"flags": 10,
"timestamp": "0"
}
]Submit the file once:
parix tb create-accounts <database-id> --file ./wallet-accounts.json --jsonA successful create has an empty responsePayload ([]). A conflict is a nonempty tbResults array associated with zero-based input indexes; inspect every returned result before proceeding.
Top up, purchase, and refund
Save this one-event bare array as wallet-top-up.json:
[
{
"id": "82000000000000000001",
"debit_account_id": "81000000000000000001",
"credit_account_id": "81000000000000000002",
"amount": "10000",
"pending_id": "0",
"user_data_128": "0",
"user_data_64": "0",
"user_data_32": 0,
"timeout": 0,
"ledger": 840,
"code": 1000,
"flags": 0,
"timestamp": "0"
}
]Run the top-up, then use stable IDs for the purchase and refund:
parix tb create-transfers <database-id> --file ./wallet-top-up.json --json
parix tb create-transfers <database-id> \
--id 82000000000000000002 \
--from 81000000000000000002 \
--to 81000000000000000003 \
--amount 2500 \
--ledger 840 \
--code 1010 \
--json
parix tb create-transfers <database-id> \
--id 82000000000000000003 \
--from 81000000000000000003 \
--to 81000000000000000002 \
--amount 500 \
--ledger 840 \
--code 1020 \
--jsonThese examples leave user_data_* at zero. The transfer id is the idempotency key; optional user_data_* is only needed when you will query by an external “who/what” correlation. See Optional user_data fields.
Do not run the refund until your application has validated the original purchase and remaining refundable amount. If a create command loses its HTTP response, look up the original ID before retrying:
parix tb lookup-transfers <database-id> --id 82000000000000000002 --jsonIf the lookup finds the intended transfer, treat the original write as committed. If it returns no match and the failure is retryable, resubmit the identical transfer with the identical ID.
Inspect history and ledger-scoped results:
parix tb get-account-transfers <database-id> \
--account-id 81000000000000000002 \
--limit 100 \
--flag debits \
--flag credits \
--json
parix tb query-transfers <database-id> \
--ledger 840 \
--limit 100 \
--jsonThe --ledger 840 filter is mandatory for shared queries and is a good production habit for every plan.
Node.js implementation
The adapter uses TigerBeetle-shaped records and bigint values over the Parix HTTP gateway. Install a pinned package version before use:
npm install @parix/tigerbeetle-nodeThe following implementation creates complete account and transfer objects, treats nonempty create-result arrays as item conflicts (and reconciles idempotent success via exact lookup), retains stable IDs, checks an ambiguous write by lookup before one retry, scopes the shared query by ledger, and always destroys the client.
import {
AccountFilterFlags,
AccountFlags,
CreateTransferError,
QueryFilterFlags,
TransferFlags,
createClient,
type Account,
type Client,
type CreateAccountResult,
type CreateTransferResult,
type Transfer,
} from '@parix/tigerbeetle-node';
const LEDGER = 840;
const IDS = {
reserve: 81000000000000000001n,
customer: 81000000000000000002n,
merchant: 81000000000000000003n,
topUp: 82000000000000000001n,
purchase: 82000000000000000002n,
refund: 82000000000000000003n,
} 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(id: bigint, debitAccountId: bigint, creditAccountId: bigint, amount: bigint, code: number): Transfer {
return {
id,
debit_account_id: debitAccountId,
credit_account_id: creditAccountId,
amount,
pending_id: 0n,
user_data_128: 0n,
user_data_64: 0n,
user_data_32: 0,
timeout: 0,
ledger: LEDGER,
code,
flags: TransferFlags.none,
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('Wallet 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, already-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;
}
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.reserve, 100, false),
account(IDS.customer, 110, true),
account(IDS.merchant, 120, true),
];
await ensureAccounts(client, accounts);
await createTransferWithOneAmbiguousRetry(client, transfer(IDS.topUp, IDS.reserve, IDS.customer, 10000n, 1000));
await createTransferWithOneAmbiguousRetry(client, transfer(IDS.purchase, IDS.customer, IDS.merchant, 2500n, 1010));
await createTransferWithOneAmbiguousRetry(client, transfer(IDS.refund, IDS.merchant, IDS.customer, 500n, 1020));
const customerHistory = await client.getAccountTransfers({
account_id: IDS.customer,
user_data_128: 0n,
user_data_64: 0n,
user_data_32: 0,
code: 0,
timestamp_min: 0n,
timestamp_max: 0n,
limit: 100,
flags: AccountFilterFlags.debits | AccountFilterFlags.credits,
});
const ledgerTransfers = await client.queryTransfers({
user_data_128: 0n,
user_data_64: 0n,
user_data_32: 0,
ledger: LEDGER,
code: 0,
timestamp_min: 0n,
timestamp_max: 0n,
limit: 100,
flags: QueryFilterFlags.none,
});
console.log({ customerHistory, ledgerTransfers });
} finally {
client.destroy();
}
}
void main();In a real service, allocate and persist the IDs before entering the retry loop, provision accounts separately from the payment path, and place retry work in a durable queue or outbox. The fixed IDs above make the retry boundary visible; they are not a production ID-generation scheme.
Failure and retry handling
Create success and create conflict have deliberately different shapes: a successful create_accounts or create_transfers returns []; rejected items produce a result array. The Node adapter returns TigerBeetle conflict results from createAccounts() or createTransfers() instead of throwing, so an unchecked nonempty array is a lost failure.
| Signal | Interpretation | Action |
|---|---|---|
Empty create result [] | Adapter reported no item conflicts | Still look up every stable ID and exact immutable fields before advancing durable state. The adapter does not expose gateway persisted. |
| Nonempty create result | Indexed TigerBeetle item conflicts | Map every index to its input and classify result (for example CreateTransferError.exceeds_credits). The adapter unwraps HTTP 409 + tbResults into this array rather than throwing. Look up every submitted ID; the gateway may return only the first ten conflict entries. |
HTTP 400 | Invalid strict payload, path, field, or batch shape | Fix the request. Do not retry unchanged. |
HTTP 401 or 403 | Credential, scope, database boundary, shared-ledger, or plan-policy failure | Correct authentication or authorization. The CLI OAuth session and server API key are not interchangeable production-auth patterns. |
HTTP 402 | Developer billing state blocks the operation | Restore billing, then re-evaluate the original request with the same stable ID. |
HTTP 429 | Quota, rate limit, or shared-cell admission limit | Distinguish quota exhaustion from transient rate/admission pressure. Back off only where appropriate; preserve IDs and do not assume an unobserved write failed. |
HTTP 503 | New deployment warming, missing shared placement, or unavailable gateway path | Retry warming with bounded backoff. Escalate persistent placement failures. For a write, look up its ID before resubmission. |
HTTP 500, connection reset, or client timeout | The write outcome may be ambiguous | Look up every submitted stable ID. Accept an exact match as committed; retry an absent event only with the identical ID and payload. |
| Lookup returns no record | The queried ID is not present at the time of the read | If the original error is retryable, resubmit the identical object. Never substitute a fresh ID. |
There is no request-level idempotency key for TigerBeetle operations. The account or transfer ID is the idempotency boundary. For a multi-item batch, retain the original ordering until every result is reconciled. Only a deliberately constructed linked chain within one create_transfers call succeeds or fails atomically; ordinary batch neighbors can have independent outcomes.
The current gateway can return only the first ten create conflicts. When a failed batch has more conflict possibilities, an input omitted from tbResults is not proven successful. Look up its stable ID or use smaller batches until every input has a confirmed outcome.
Test scenarios
Run these scenarios in an isolated non-production database and assert ledger records as well as API status:
| Scenario | Setup and action | Expected result |
|---|---|---|
| Account provisioning | Create reserve, customer, and merchant accounts with the documented flags | Empty create result; lookup returns all three roles on ledger 840. |
| Top-up | Credit 10,000 cents from reserve to customer | Customer posted credits increase by 10,000; history contains transfer code 1000. |
| Purchase | Debit customer 2,500 cents to merchant | Customer available balance falls by 2,500; merchant posted credits rise by 2,500. |
| Concurrent no-overdraft | Submit purchases whose combined amount exceeds the remaining customer balance | At most the affordable movements commit; rejected results include the debit constraint failure. Balance never becomes negative under the selected convention. |
| Partial refund | Debit merchant 500 cents to customer with a new stable ID | Both balances move by 500 and the original purchase remains unchanged. |
| Excess refund | Attempt to debit more than the constrained merchant balance | Refund is rejected and neither wallet balance changes. |
| Exact duplicate delivery | Submit the identical purchase ID and fields again | No second movement is created. The create result identifies the existing record; lookup matches the intended transfer. |
| ID collision | Reuse the purchase ID with a different amount or account | Create is rejected. Operations alert on the invariant breach; no replacement ID is generated automatically. |
| Ambiguous timeout | Drop the response after submission, then run lookup-before-retry | An existing exact record is accepted once; an absent record is retried with the same ID. There is never a duplicate business movement. |
| History | Read customer transfers with both debit and credit flags | Top-up, purchase, and refund appear with their original IDs and codes. |
| Shared query boundary | Query transfers with and without ledger 840 on Developer | Ledger-scoped query succeeds; missing-ledger query is rejected by the shared boundary. |
| Independent versus linked batch | Inject a failure into an ordinary batch and then into a linked chain | Ordinary items can resolve independently; every event in the linked chain rolls back together. |
Production operations
- Run production wallets only on a Production or Enterprise plan. Dedicated Single Node provides isolation but is still a non-production, non-HA posture.
- Keep API keys in server-side secret storage, bind them to the required database where possible, rotate them, and never log them. The CLI's local OAuth session is for humans, not services.
- Maintain reviewed registries for ledger, account, and transfer codes. Treat semantic changes as versioned migrations, not ad hoc number reuse.
- Use an inbox/outbox or equivalent durable workflow so the stable transfer ID and external provider reference survive process crashes.
- Reconcile reserve movements to processor or bank settlement, customer and merchant control totals to application records, and every refund to its purchase authorization. Investigate differences through a governed suspense or adjustment process.
- Monitor database health, request failures, shared quota/rate pressure, latency, and no-overdraft rejections. Separate expected business declines from platform errors.
- Enable and exercise backups and recovery where the selected production plan and provider support them. A backup does not replace external settlement reconciliation.
- Keep batch sizes within the public maximum of 8190 and the selected plan's lower limits. Use bounded batches whose individual business events can be traced by stable ID.
- Never assume a ledger write and an external card, bank, notification, or general-ledger action are atomic. Design compensation and recovery for every boundary.
- Preserve immutable transfer IDs and non-sensitive correlation IDs in structured logs. Keep PII and payment credentials out of TigerBeetle records and operational logs.