Transactions
A transaction is one business fact — a date, a narration, and a set of entries. It carries no amount of its own. The money lives entirely in its entries, whose debits equal their credits.
Amounts are integers in minor units. 150000 means ₹1,500.00. Decimals are
refused, not rounded.
The model in one line
A transaction is a dated fact. Its entries are how it lands on accounts. The entries' debits equal their credits.
POST /api/v1/transactions
Create a transaction and its entries in one atomic call. Either the whole thing is written or nothing is — a rejected call leaves no partial rows behind, so retrying after a validation error is safe.
Request
{
"date": "2026-08-05",
"narration": "Rent paid — Aug 2026",
"type": "vendor_paid",
"status": "posted",
"source": "sap",
"source_ref": "4900001234",
"entries": [
{ "account": "a1b2c3d4e5f6", "debit": 3000000, "credit": 0, "dimensions": { "cost_centre": "Mumbai" } },
{ "account": "a1b2c3d4e5f6", "debit": 2000000, "credit": 0, "dimensions": { "cost_centre": "Delhi" } },
{ "account": "f6e5d4c3b2a1", "debit": 0, "credit": 5000000 }
]
}
| Field | Required | Notes |
|---|---|---|
date | yes | YYYY-MM-DD. The accounting date, not when it was keyed in |
entries | yes for posted | Array; see below |
time | no | HH:MM or HH:MM:SS, wall-clock as the source printed it |
narration | no | What happened, in words |
type | no | Business event, free text — vendor_paid, sales_recorded |
status | no | posted (default) or draft |
currency | no | Defaults to INR |
source | no | manual (default), or the upstream system's name |
source_ref | no | The upstream document number |
details | no | The upstream event as it arrived, any shape |
Each entry takes account, one of debit or credit, and optionally memo
and dimensions.
What is enforced
- Debits equal credits for anything reaching
posted - At least two entries on a posted transaction
- No entry against a group account — post to its children
- Every account exists in this organization and is postable
Drafts are exempt from 1 and 2: a draft is work in progress, and refusing to save a half-built one would leave nowhere to put it.
Idempotency
When source and source_ref are both given, they are the transaction's
identity. Re-posting one already sent returns the existing transaction with
created: false rather than creating a second copy:
{ "data": { "id": "...", "created": false, "...": "..." } }
This is what makes an interrupted sync safe to re-run — for a shadow ledger the normal case, not an edge one.
A re-post does not update the existing transaction. A posted transaction is immutable; correcting one means voiding it and posting a replacement.
Response
201 with created: true, or 200 with created: false. The body carries the
transaction, its entries, and total_debit / total_credit.
GET /api/v1/transactions
| Param | Notes |
|---|---|
date_from, date_to | Inclusive, YYYY-MM-DD |
status | draft, posted or void |
type | Exact match on the business event |
source, source_ref | Upstream identity; together they resolve one row |
account | Only transactions with an entry against this account |
dimension.<key> | e.g. dimension.cost_centre=Mumbai |
q | Substring match on narration |
include_entries | false omits entries; they are included by default |
sort_order | ASC or DESC on date, default DESC |
page, limit | Default 100 per page, max 500 |
Entries come back with each transaction by default, along with total_debit and
total_credit, so a list is usable without a follow-up call per row.
Results are ordered date, time NULLS LAST, created_at, id — deterministic
whether or not a source supplied a time.
# Everything hitting one account in Q1, with its entries
curl "https://ledger.finopsbricks.com/api/v1/transactions?account=a1b2c3d4e5f6&date_from=2026-01-01&date_to=2026-03-31" \
-H "api-key: $KEY" -H "api-secret: $SECRET"
GET /api/v1/transactions/:id
One transaction with its entries and totals.
PATCH /api/v1/transactions/:id
Two different operations, one at a time.
Changing status
{ "status": "posted" }
The lifecycle:
draft ──post──> posted ──void──> void
└───void───────────────────────┘
posted is the point of no return. The balance check bites on the way there.
After it, a mistake is corrected by voiding and posting a replacement — which is
what keeps a shadow ledger reconcilable against the system it mirrors.
Editing content
Drafts only. Send any of date, time, narration, type, currency,
details, entries.
entries is replaced wholesale, not merged. Sending three entries leaves the
transaction with exactly those three. Patching individual lines would let a
caller leave a transaction half-edited with only the eventual balance check
noticing.
Editing a posted transaction returns TRANSACTION_IMMUTABLE (409).
DELETE /api/v1/transactions/:id
Drafts only. A posted transaction is voided, never deleted — removing the record that a document was mirrored defeats the purpose of mirroring it.
Dimensions
Dimensions sit on the entry, not the transaction. The rent example above is why: one payment split across two branches has expense lines under different cost centres and a bank line under none. That shape cannot be expressed a level up.
Keys are yours to choose. Filter with dimension.<key>=<value> on either this
endpoint or entries.