Skip to main content
PARIXDocs

Metering

Enforce integer usage allowances and produce durable billing inputs with Parix.

Overview

This manual builds an allowance-based meter: grant a customer an integer quantity, then move each confirmed usage quantity from that allowance account to a usage sink. The balance constraint on the allowance account rejects consumption that exceeds the grant, including concurrent requests.

Use this pattern for API calls, tokens, messages, compute milliseconds, byte-hours, or any other quantity that can be represented as an integer base unit. Choose the base unit before creating accounts. For example, record bytes instead of gigabytes and milliseconds instead of fractional seconds. Never send a floating-point amount to the ledger.

The ledger preserves quantity movements. Price books, rates, discounts, taxes, invoice documents, and payment collection stay outside the usage ledger. A consumed-usage account is a quantity sink; it is not revenue.

Architecture and ownership

ComponentOwnsDoes not own
Product or ingestion serviceAuthentication, raw source event, measured quantity, event time, and durable source-event IDLedger balance mutation
Metering serviceUnit conversion, customer/period lookup, stable source-event-to-transfer mapping, and allowance policyPrice calculation or invoice presentation
Parix API gatewayCredential and database-boundary checks, strict request validation, plan enforcement, and routingProduct identity, pricing, or retry scheduling
TigerBeetle ledgerAccount balances, transfer history, concurrency-safe constraints, and transfer-ID idempotencyRaw telemetry, contracts, or invoice state
Billing pipelineRate and price-book version, aggregation, corrections, invoice input, and reconciliationRewriting confirmed usage history

Applications call the versioned Parix HTTP gateway. They do not connect a native TigerBeetle client directly to replica addresses. The Node adapter used later in this guide keeps TigerBeetle-shaped objects but sends authenticated HTTP requests through that gateway.

A typical request path is:

  1. Persist the raw event with an immutable source-event ID.
  2. Resolve the customer's allowance account for the applicable period.
  3. Load the already-persisted transfer ID for that source event, or allocate and persist one before submitting the ledger write.
  4. Transfer the integer quantity from the allowance account to the usage sink.
  5. Treat a balance-constraint result as a business rejection, not an infrastructure retry.
  6. Project confirmed transfers for customer usage views and invoice input.

This ordering keeps a queue redelivery, HTTP retry, and application restart attached to the original ledger ID.

Ledger model

The examples use ledger 7100 for one product's API-request units. Allocate another ledger for a dimension that cannot be added to or transferred with these units. An input-token ledger and a byte-hour ledger, for example, should not share balances merely because both eventually appear on one invoice.

Accounts

AccountID in examplesCodeBalance rulePurpose
Allowance source710000000000000000011001UnconstrainedSupplies grants; it is a control account, not a customer balance
Customer allowance710000000000000000021002Debits must not exceed creditsReceives grants and is debited by usage
Usage sink710000000000000000031003UnconstrainedAccumulates consumed quantity; it is not revenue

Set debits_must_not_exceed_credits on the customer allowance. With no pending transfers, available units are credits_posted - debits_posted. If you introduce pending reservations, subtract debits_pending as well and define how expired or voided reservations affect the product response.

Transfer codes

CodeNameDirectionStable business key
2001Allowance grantAllowance source to customer allowanceCustomer ID + grant period + grant version
2002Usage consumeCustomer allowance to usage sinkSource usage-event ID
2003Usage adjustmentDirection depends on the approved correctionAdjustment/case ID
2004Usage refundUsage sink to customer allowanceRefund ID + original usage-event ID

The usage sink is unconstrained so operational refunds can reverse prior consumption, but that means a refund is an application-authorized mint of allowance. Gate every 2004 on a unique refund case bound to a prior 2002 consumption and cumulative remaining refundable quantity; do not issue refunds from sink alone.

Do not reuse a transfer ID for a materially different event. Persist at least this mapping in the application database:

Application fieldExampleLedger field
source_event_idusage_evt_01J...Maps one-to-one to transfer 72000000000000000002
customer_idcustomer_410001Customer account lookup; optionally encoded in user_data_128
period_id2026-08Grant account/mapping; optionally encoded in user_data_64
metric_idapi_requestsLedger and code selection; optionally encoded in user_data_32
quantity_base_units1250Transfer amount
price_book_versionapi-2026-08-v3Application billing record only

Rates and invoice totals are deliberately absent from this ledger. Query confirmed 2002 transfers for the desired customer and period, then apply the retained price-book version in the billing system. Do not change historical transfer amounts when a rate changes.

Before you begin

  • Use Developer for learning, prototypes, and SDK testing. Use Dedicated Single Node for isolated development, staging, or other non-HA work. Neither plan is production-allowed.
  • Use Production HA, Production 6, or a contract-defined Enterprise topology for production. Provider, region, and written-SLA availability remain environment- and contract-specific.
  • Confirm the database profile is active and copy its database UUID. API and CLI operations require the UUID, not the display name from a dashboard route.
  • Allocate the unit, ledger, account codes, transfer codes, rounding rule, and period policy before creating durable data.
  • Persist account IDs and source-event-to-transfer IDs in your application database. Never generate a replacement transfer ID because an earlier outcome is uncertain.
  • Create a database-scoped API key for a server application and store it in a secret manager. Generated API keys currently carry both read and write scopes, so database scope is the narrowest shipped boundary.
  • For raw HTTP JSON, send IDs, amounts, timestamps, and other wide integers as decimal strings. Create and lookup requests are non-empty arrays with at most 8,190 items.
  • A batch is not atomic merely because it is one request. Independent items can partially succeed. Use a correctly terminated linked chain only when the business operation truly requires all-or-nothing behavior, and test its failure results.
  • Shared Developer query_accounts and query_transfers requests require a ledger. Always include 7100 for this model. The first use of an external ledger can allocate a Parix project-ledger mapping and consume one of the plan's ledger slots, even though the TigerBeetle operation itself is a read; reuse governed application ledgers rather than probing arbitrary values.

Use new example IDs or a disposable database while following the walkthroughs. The CLI and Node sections reuse the same business model but are alternative integration surfaces, not two steps to run with identical IDs.

Dashboard walkthrough

  1. Open the intended database and select Query.
  2. Start with Query accounts, set the governed ledger 7100, keep a small limit, and select Run. An empty result is a successful TigerBeetle read when the ledger has no accounts yet. On Shared Developer, first use of that external ledger may also allocate its Parix namespace mapping and consume one ledger slot.
  3. Select Create accounts and enter the three stable IDs, ledger 7100, the account codes above, and the customer allowance flag. Review every value before running it.
  4. Select Create transfers. Grant the allowance with code 2001, then submit consumption with code 2002 from the customer allowance to the usage sink.
  5. Return to Query transfers, filter by ledger 7100 and code 2002, and compare the result with the source-event mapping in your application database.

Query is connected to the selected live database. create_accounts and create_transfers write immediately when you select Run; the dashboard is not a dry-run simulator. Generated IDs are conveniences for exploration, but production writes must use the stable IDs stored by your application.

Create accounts form in the Parix query explorer, showing account ID, ledger, code, and flags fields

This screen shows the account-create form only. It does not show a metering transfer or prove that a write succeeded.

The dashboard is an operator and development surface. It does not replace a server-side application credential, source-event store, or automated reconciliation process.

CLI walkthrough

The commands below use the latest published @parix/cli package.

Install and authenticate

npm install --global @parix/cli@latest
parix --version
parix auth login
parix auth status
parix database list --json

The CLI defaults to https://parix.io. Use --base-url consistently on login and later commands only when targeting another environment. Login opens an OAuth browser flow and stores a local operator session. It is appropriate for terminal-driven development and operations; a production service should use a scoped API key instead.

Set the UUID returned by the database list:

export PARIX_DATABASE_ID="db_replace_with_database_uuid"

Create the accounts from a reviewed file

Save this exact bare JSON array as metering-accounts.json. The file must contain the array itself, with no wrapper such as { "accounts": [...] }.

[
  {
    "id": "71000000000000000001",
    "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": 7100,
    "code": 1001,
    "flags": 8,
    "timestamp": "0"
  },
  {
    "id": "71000000000000000002",
    "debits_pending": "0",
    "debits_posted": "0",
    "credits_pending": "0",
    "credits_posted": "0",
    "user_data_128": "410001",
    "user_data_64": "202608",
    "user_data_32": 1,
    "reserved": 0,
    "ledger": 7100,
    "code": 1002,
    "flags": 10,
    "timestamp": "0"
  },
  {
    "id": "71000000000000000003",
    "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": 7100,
    "code": 1003,
    "flags": 8,
    "timestamp": "0"
  }
]

Review the file, then submit it:

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

For a clean create, the response envelope's responsePayload is []. A conflict contains per-item results; an HTTP response alone is not permission to discard the IDs.

Grant and consume usage

Create the grant with an explicitly stable ID. In a real system, load this ID from the persisted customer-period grant record. This example sets optional user_data_* only because the later invoice query filters on them (TigerBeetle indexes these fields; leave them zero when you will not query by them):

  • user_data_128 — opaque customer correlation (“who”)
  • user_data_64 — billing period key as an integer (“when” / period identity)
  • user_data_32 — metric enum (1 = api_requests in this product)
parix tb create-transfers "$PARIX_DATABASE_ID" \
  --id 72000000000000000001 \
  --from 71000000000000000001 \
  --to 71000000000000000002 \
  --amount 100000 \
  --ledger 7100 \
  --code 2001 \
  --user-data-128 410001 \
  --user-data-64 202608 \
  --user-data-32 1 \
  --json

For more than one event, use a reviewed file so shell quoting cannot change the payload. Save this bare array as metering-usage-events.json. The two transfer IDs must already be mapped to the two source events in durable application storage.

[
  {
    "id": "72000000000000000002",
    "debit_account_id": "71000000000000000002",
    "credit_account_id": "71000000000000000003",
    "amount": "1250",
    "pending_id": "0",
    "user_data_128": "410001",
    "user_data_64": "202608",
    "user_data_32": 1,
    "timeout": 0,
    "ledger": 7100,
    "code": 2002,
    "flags": 0,
    "timestamp": "0"
  },
  {
    "id": "72000000000000000003",
    "debit_account_id": "71000000000000000002",
    "credit_account_id": "71000000000000000003",
    "amount": "750",
    "pending_id": "0",
    "user_data_128": "410001",
    "user_data_64": "202608",
    "user_data_32": 1,
    "timeout": 0,
    "ledger": 7100,
    "code": 2002,
    "flags": 0,
    "timestamp": "0"
  }
]
parix tb create-transfers "$PARIX_DATABASE_ID" \
  --file ./metering-usage-events.json \
  --json

These items use flags: 0, so they are independent; the batch can partially succeed. Match every non-empty result's zero-based index back to the unchanged file before deciding what to retry.

Verify the known IDs, then query the ledger-scoped invoice input:

parix tb lookup-transfers "$PARIX_DATABASE_ID" \
  --id 72000000000000000002 \
  --id 72000000000000000003 \
  --json

parix tb query-transfers "$PARIX_DATABASE_ID" \
  --ledger 7100 \
  --code 2002 \
  --user-data-128 410001 \
  --user-data-64 202608 \
  --user-data-32 1 \
  --limit 100 \
  --json

--json prints the full Parix response envelope. Use responsePayload as the TigerBeetle result. The query selects metric 1 for customer 410001 and period 202608; it supplies confirmed integer usage to a billing calculation but does not calculate a price or issue an invoice.

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 is an HTTP gateway client, not the native TigerBeetle transport.

The example uses literal IDs to stand in for rows loaded from durable application storage. In production, allocate each account or transfer ID once, persist it with the customer/period or source event, and reload it on every attempt. Do not call an ID generator inside retry code.

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

const USAGE_LEDGER = 7100;
const ACCOUNT_CODE = {
  allowanceSource: 1001,
  customerAllowance: 1002,
  usageSink: 1003,
} as const;
const TRANSFER_CODE = {
  grant: 2001,
  consume: 2002,
} as const;

interface PersistedMeteringIds {
  customerId: bigint;
  periodId: bigint;
  allowanceSourceAccountId: bigint;
  customerAllowanceAccountId: bigint;
  usageSinkAccountId: bigint;
  grantTransferId: bigint;
  sourceEventId: string;
  consumptionTransferId: bigint;
}

// Replace these demo literals with one row loaded from durable storage.
const persisted: PersistedMeteringIds = {
  customerId: 410001n,
  periodId: 202608n,
  allowanceSourceAccountId: 71000000000000000001n,
  customerAllowanceAccountId: 71000000000000000002n,
  usageSinkAccountId: 71000000000000000003n,
  grantTransferId: 72000000000000000001n,
  sourceEventId: 'usage_evt_01JEXAMPLE',
  consumptionTransferId: 72000000000000000002n,
};

function account(input: {
  id: bigint;
  code: number;
  flags: number;
  customerId?: bigint;
  periodId?: bigint;
  metricId?: number;
}): Account {
  return {
    id: input.id,
    debits_pending: 0n,
    debits_posted: 0n,
    credits_pending: 0n,
    credits_posted: 0n,
    user_data_128: input.customerId ?? 0n,
    user_data_64: input.periodId ?? 0n,
    // Match CLI JSON: metric id only on the customer allowance; control accounts keep 0.
    user_data_32: input.metricId ?? 0,
    reserved: 0,
    ledger: USAGE_LEDGER,
    code: input.code,
    flags: input.flags,
    timestamp: 0n,
  };
}

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

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

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

async function ensureAccounts(client: Client, expected: Account[]): Promise<void> {
  let results: CreateAccountResult[] | CreateTransferResult[];
  try {
    results = await client.createAccounts(expected);
  } catch (error) {
    // On thrown transport/HTTP errors: resolve by exact lookup before rethrowing.
    const actual = await client.lookupAccounts(expected.map((item) => item.id));
    const byId = new Map(actual.map((item) => [item.id, item]));
    const allMatch =
      actual.length === expected.length &&
      expected.every((item) => {
        const found = byId.get(item.id);
        return found !== undefined && sameAccount(found, item);
      });
    if (allMatch) return;
    throw error;
  }

  // Adapter omits gateway `persisted`; require exact post-write lookup always.
  const actual = await client.lookupAccounts(expected.map((item) => item.id));
  const byId = new Map(actual.map((item) => [item.id, item]));
  const mismatch = expected.find((item) => {
    const found = byId.get(item.id);
    return !found || !sameAccount(found, item);
  });

  if (!mismatch) return;
  if (results.length === 0) {
    throw new Error('Account write was not confirmed by lookup; do not advance or change the payload');
  }
  throw new Error(`createAccounts conflict at account ${mismatch.id}`);
}

async function transferAlreadyExists(client: Client, expected: Transfer): Promise<boolean> {
  const [actual] = await client.lookupTransfers([expected.id]);
  if (!actual) {
    return false;
  }
  if (!sameTransfer(actual, expected)) {
    throw new Error(`transfer ID ${expected.id} exists with different fields`);
  }
  return true;
}

async function createTransferWithLookup(client: Client, expected: Transfer): Promise<void> {
  // This lookup runs before every scheduler or queue retry.
  if (await transferAlreadyExists(client, expected)) {
    return;
  }

  try {
    const results = await client.createTransfers([expected]);
    // Adapter omits gateway `persisted`; confirm by exact lookup before advancing.
    if (await transferAlreadyExists(client, expected)) {
      return;
    }
    if (results.length === 0) {
      throw new Error('Transfer write was not confirmed by lookup; do not advance or change the payload');
    }
    throw new Error(
      `createTransfers rejected input 0 with result ${results[0]?.result ?? 'unknown'}` +
        (results[0]?.result === CreateTransferError.exceeds_credits ? ' (exceeds_credits)' : ''),
    );
  } catch (error) {
    // A timeout can happen after acceptance. Resolve the ID before allowing a
    // later attempt to submit the same object again.
    if (await transferAlreadyExists(client, expected)) {
      return;
    }
    throw error;
  }
}

async function run(client: Client): Promise<void> {
  const accounts = [
    account({
      id: persisted.allowanceSourceAccountId,
      code: ACCOUNT_CODE.allowanceSource,
      flags: AccountFlags.history,
    }),
    account({
      id: persisted.customerAllowanceAccountId,
      code: ACCOUNT_CODE.customerAllowance,
      flags: AccountFlags.history | AccountFlags.debits_must_not_exceed_credits,
      customerId: persisted.customerId,
      periodId: persisted.periodId,
      metricId: 1,
    }),
    account({
      id: persisted.usageSinkAccountId,
      code: ACCOUNT_CODE.usageSink,
      flags: AccountFlags.history,
    }),
  ];
  await ensureAccounts(client, accounts);

  await createTransferWithLookup(
    client,
    transfer({
      id: persisted.grantTransferId,
      debitAccountId: persisted.allowanceSourceAccountId,
      creditAccountId: persisted.customerAllowanceAccountId,
      amount: 100_000n,
      code: TRANSFER_CODE.grant,
      customerId: persisted.customerId,
      periodId: persisted.periodId,
    }),
  );

  // consumptionTransferId is persisted next to persisted.sourceEventId.
  await createTransferWithLookup(
    client,
    transfer({
      id: persisted.consumptionTransferId,
      debitAccountId: persisted.customerAllowanceAccountId,
      creditAccountId: persisted.usageSinkAccountId,
      amount: 1_250n,
      code: TRANSFER_CODE.consume,
      customerId: persisted.customerId,
      periodId: persisted.periodId,
    }),
  );

  const invoiceInput = await client.queryTransfers({
    user_data_128: persisted.customerId,
    user_data_64: persisted.periodId,
    user_data_32: 1,
    ledger: USAGE_LEDGER,
    code: TRANSFER_CODE.consume,
    timestamp_min: 0n,
    timestamp_max: 0n,
    limit: 100,
    flags: QueryFilterFlags.none,
  });

  console.log({ sourceEventId: persisted.sourceEventId, invoiceInput });
}

const baseUrl = process.env.PARIX_BASE_URL;
const apiKey = process.env.PARIX_API_KEY;
const databaseId = process.env.PARIX_DATABASE_ID;
if (!baseUrl || !apiKey || !databaseId) {
  throw new Error('PARIX_BASE_URL, PARIX_API_KEY, and PARIX_DATABASE_ID are required');
}

const client = createClient({ baseUrl, apiKey, databaseId });
try {
  await run(client);
} finally {
  client.destroy();
}

The adapter uses bigint in application objects and serializes wide values to decimal strings for HTTP. createAccounts and createTransfers return [] when every submitted item succeeds. A non-empty array identifies rejected input indexes, including conflicts returned by the gateway. Never ignore that array.

The catch path deliberately does not invent a new ID or blindly resubmit. If neither the create result nor lookup proves acceptance, let the durable job remain retryable with the same persisted object and ID.

Failure and retry handling

SignalMeaningAction
Create returns []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 item conflictsMatch index to the original batch; classify each result (for example CreateTransferError.exceeds_credits). HTTP 409 + tbResults is unwrapped into this array. Look up every stable ID.
Balance result such as exceeds_creditsAllowance is insufficientReject or apply the documented grace policy; do not retry unchanged
Validation 400Payload shape, range, or strict field validation failedFix the producer; do not retry the same payload
401 or 403Credential invalid, missing scope, or outside its database boundaryCorrect credential/configuration before retrying
402Plan or billing eligibility blocks the requestResolve the returned plan condition before retrying
429Rate, admission, monthly, or lifetime quota reachedRetry only transient rate/admission pressure with bounded jitter; wait for a renewable window or change capacity for a hard quota. Keep every original ID.
Timeout, connection reset, or 5xx/503Outcome may be unknownLookup every submitted ID first; retry only absent items with identical fields and IDs
Shared query fails without a ledgerShared query contract was not metAdd ledger 7100; do not fan out implicitly in application code
Mixed result from an unlinked batchIndependent items partially succeededReconcile by index and ID; never resend the entire batch blindly

If an event must update multiple balances atomically, design and test a linked transfer chain. In a linked chain, set linked on every transfer except the last. A plain array, including the CLI example above, has no all-or-nothing guarantee.

The current gateway can expose only the first ten create-conflict entries. In a failed larger batch, an input omitted from the conflict array is not proven successful. Resolve every stable ID by lookup or use smaller batches until every outcome is known.

Test scenarios

ScenarioSetup and actionExpected invariant and evidence
Grant then consumeGrant 100000, consume 1250Allowance is 98750; one 2001 and one 2002 transfer are discoverable
Duplicate deliverySubmit the same source event twice with the same transfer ID and fieldsExactly one consumption exists; retry resolves through lookup
ID collisionReuse the transfer ID with a different amount or accountConflict is surfaced and treated as data corruption, not success
Concurrent limit raceSubmit concurrent consumptions whose total exceeds the remaining allowanceThe constraint prevents total posted debits from exceeding credits
Exhausted allowanceConsume after no units remainBusiness rejection; no new confirmed consumption transfer
Partial batchMake one item valid and one invalid in an unlinked batchResults are reconciled by index; the valid item is not duplicated on recovery
Ambiguous timeoutDrop the response after the gateway accepts a transferLookup finds the original ID; recovery does not create another event
RepricingChange the price book after usage is confirmedLedger quantities remain unchanged; invoice calculation retains its selected price version
Shared query scopeQuery without and then with ledger 7100 on DeveloperMissing-ledger request fails; ledger-scoped request returns only the intended dimension
Numeric boundarySend values beyond JavaScript's safe integer range as decimal strings/bigintExact values round-trip without precision loss
Batch limitAttempt 8,191 create itemsClient splits the work or the strict request is rejected before any oversized batch is submitted
RefundTransfer units from sink back to allowance with code 2004 and a stable refund IDAvailable allowance increases once and the original consumption remains auditable

Run concurrency and ambiguous-response tests against a disposable environment. A unit test that only mocks createTransfers([]) does not prove balance constraints or retry safety.

Production operations

  • Reconcile four durable sets: accepted raw events, source-event-to-transfer mappings, ledger transfers, and invoice inputs. Alert on missing or conflicting links in either direction.
  • Record sanitized database ID, ledger, transfer ID, source-event ID, result index/code, and request correlation in logs. Never log API keys or unrestricted payloads.
  • Monitor API error classes, Developer quota counters where applicable, database health, request latency, and backlog age independently. An allowance rejection is not an infrastructure incident.
  • Keep the unit definition, code registry, period policy, late-event policy, and price-book versioning rules under change control. Add new codes rather than silently changing the meaning of old ones.
  • Bound batches to 8,190 items and size them below operational timeouts. Preserve input ordering until every result index has been reconciled.
  • Rotate production credentials with an overlap/cutover procedure and keep each key scoped to one database when possible.
  • Exercise backup and restore procedures on plans that support them. A backup does not replace event-to-ledger reconciliation or the source telemetry store.
  • Build customer dashboards from a projection or cache fed by confirmed ledger data. Do not place wide historical scans in the synchronous request path.
  • Define period-close and late-event behavior explicitly: hold the period open for a documented lateness window, assign the event to the next period, or post a named adjustment. Never silently rewrite an old transfer.