Error Codes

Every error carries a stable code, a sentence, and — for anything the ledger refused on accounting grounds — a details object holding the figures involved.

{
  "error": {
    "code": "UNBALANCED",
    "message": "Debits and credits must be equal. Debits total 5000000, credits total 4500000, a difference of 500000.",
    "details": {
      "total_debit": 5000000,
      "total_credit": 4500000,
      "difference": 500000,
      "entry_count": 3
    }
  }
}

Read details, not the message. The message is prose that may be reworded; code and the keys in details are the interface. The difference on an UNBALANCED error is usually the line that was omitted or mistyped.

Errors never suggest a correction. The ledger states what is wrong and stops there. A caller that got the books wrong should not be handed a plausible-looking fix to apply unread — that turns one bad entry into a confidently wrong one.

Status codes

StatusMeaningWhat to change
400The request itself is malformedThe syntax — bad JSON, a missing field, a date in the wrong format
401Bad or missing credentialsThe headers
403Key lacks the required scopeThe key
404No such account or transaction in this organizationThe id
409A conflict with something protectedNothing — the operation is not allowed on that row
422Well-formed, but refused on accounting groundsThe numbers

The 400 / 422 split is the useful one. A 400 means the request was never comprehensible. A 422 means it was understood and would have broken the ledger.

Ledger error codes

Balance and entries

CodeStatusRaised when
UNBALANCED422Debits do not equal credits on a transaction being posted
NO_ENTRIES422A posted transaction has fewer than two entries
INVALID_ENTRY_AMOUNT422An entry has both sides non-zero, both zero, a negative amount, or a decimal

details for UNBALANCED carries total_debit, total_credit, difference and entry_count. For INVALID_ENTRY_AMOUNT it carries entry_index, field and received, so a forty-line transaction tells you which line is wrong.

Amounts are integers in minor units. 150000 is ₹1,500.00. A decimal is refused rather than rounded — rounding would silently move money, and you are better placed than the ledger to decide what the figure should have been.

Accounts

CodeStatusRaised when
ACCOUNT_NOT_FOUND404The account does not exist in this organization
GROUP_ACCOUNT_NOT_POSTABLE422An entry names a group heading; post to one of its children
ACCOUNT_NOT_POSTABLE422The account is quarantined
ACCOUNT_HAS_ENTRIES422Making an account a group, or deleting it, when entries point at it
INVALID_PARENT422The parent does not exist, is not a group, is a different type, or the move would form a cycle
SYSTEM_ACCOUNT_PROTECTED409Changing the type or source_ref of a seeded system account

An account belonging to another organization returns ACCOUNT_NOT_FOUND, the same as one that does not exist — so the two cannot be told apart by comparing responses.

Transaction lifecycle

CodeStatusRaised when
TRANSACTION_IMMUTABLE409Editing or deleting a transaction that is posted or void
INVALID_STATUS_TRANSITION422A status move the lifecycle does not allow

INVALID_STATUS_TRANSITION carries current_status, requested_status and allowed, so the legal moves come back with the refusal.

Request-level

CodeStatusRaised when
VALIDATION_ERROR400A required field is missing or malformed
INVALID_JSON400The body is not a JSON object

Handling errors

Branch on code. It is stable across releases; the message is not.

response = post("/api/v1/transactions", json=payload)

if response.status_code == 422:
    error = response.json()["error"]

    if error["code"] == "UNBALANCED":
        # details["difference"] is what the entries are short by
        difference = error["details"]["difference"]
        ...
    elif error["code"] == "GROUP_ACCOUNT_NOT_POSTABLE":
        # details["entry_index"] identifies the offending line
        ...

A 422 is never worth retrying unchanged — the same request will be refused identically. A 5xx is.