Build it from four reads
GET /v2/accounts/{accountId}/balances is not a closing balance. It reports custodyScope: "not_a_custody_balance" and can stay positive after value has left, so use it to sanity-check your totals, never as a figure on the document. See Transactions.
Give each line five things
Each transaction row already carries them:- Direction from that account’s point of view, read from
perspective.direction. - Amount and asset, as an atomic string plus
asset.decimals. - What produced it, from
sourceTypeandsourceId, withpaymentIdwhen it belongs to a Payment. - Which leg it is, from
financialLegKind, so collection, settlement and fee do not collapse into one figure. - Status, so a
pendingline is not presented as settled.
sourceType: "fee". Keep them as separate lines: a finance team needs gross, fee and net to tie out independently.
Print only stable fields
offrampDetails is frozen at quote time and already redacts bank account numbers to their last four characters, which makes it safe to put in front of a customer. Fields the provider did not return are null, so render an absence rather than an empty string. A txHash can be null while a transfer is unconfirmed: print the line without it rather than holding the statement.
Fee amounts come from the Payment’s fees snapshot. Print them only when fees.pricingStatus is quoted; under quote_pending the amounts are still null, and fees itself can be null where the terms are not yours to read. A genuine zero comes back as an explicit 0, and an absent fee is not a free movement. Reconcile against the snapshot, not your current rates.
Do the arithmetic in integers
Amounts are exact atomic strings. Do the arithmetic in integers, scale byasset.decimals once, and format at the edge; a statement that totals in floating point will disagree with itself.
Totals are per chain and asset. There is no cross-chain total in the API, and producing one means choosing rates, which is your policy. The only rate Stableyard freezes is on a payout, in offrampDetails.exchangeRate; it values that one movement, so do not reuse it for other lines.
Bound a period from your own store
The transaction list has nofrom or to, ordering is newest first, and the cursor is opaque. You cannot ask for March.
1
Capture continuously, do not query retrospectively
Read transactions on a schedule, and again when a webhook prompts you, and write each row into your own store keyed on its
id. Periods then close against your store.2
Date each line from dated evidence, not from when you read it
Use the Payment’s
createdAt, acceptedAt or succeededAt, a Refund’s createdAt, broadcastAt or confirmedAt, or the createdAt on a ledger entry from the transaction detail read.3
Pick one date per line kind and keep it
A statement whose lines are dated inconsistently cannot be reconciled against itself, and the inconsistency only shows up at a period boundary.
4
Record the boundary with the statement
Store the exact instant and timezone the period closed on. Every later question about a line near the edge is answered by that value.
Never rewrite a closed statement
A transaction can becomereversed, a settled deposit can become reversed, and a delivered payout can be returned downstream. Issue each correction as its own line in the open period, referencing the original line and the statement it came from.
statusVersion on a Payment tells you whether your stored copy is stale. operationalState tells you a movement stopped for review without its financial result changing, which is exactly where a statement would otherwise drift. A webhook is the prompt to re-read, never the change itself.
Next: Reconciliation
Match Stableyard records to your own.