# API conventions

Base URL: `https://api.accountingorbit.com/api/v1`

Everything on this page was verified against a running Accounting Orbit
backend on 2026-08-10/11, except the [rate-limit](#rate-limits) section, which
is the specified contract for API keys and is marked as such.

## Error shape

Every error is JSON with a `detail` key. There is no `error` envelope and no
per-error request id in the body.

**`detail` has three possible types. A client must handle all three.**

| Form | Type | Comes from |
|---|---|---|
| [String](#string-form) | `str` | Most errors — auth, routing, permissions, generic failures |
| [Validation list](#list-form-422) | `list[dict]` | Request-body / query schema validation |
| [Object](#object-form) | `dict` with `message` and `code` | Accounting-rule violations and plan gates |

This is the single most common integration bug against Orbit. Code like
`err.detail.toLowerCase()` or `err.detail.split(...)` **throws** the moment it
meets the list or object form, and the object form is exactly what you get from
the interesting failures — voiding a paid invoice, paying an unapproved bill,
posting an unbalanced entry. Type-check before you touch `detail`; see
[Parsing errors safely](#parsing-errors-safely).

### String form

```bash
curl -s https://api.accountingorbit.com/api/v1/nope
```
```json
{"detail": "Not Found"}
```

| Status | Example body | When |
|---|---|---|
| 400 | `{"detail": "Operation failed — the requested action could not be completed."}` | A ledger rule was violated (unbalanced posting, closed period, invalid account). The public message is deliberately generic and does not name the cause; it is not safe to parse. Narrow it down by re-sending the parts of the request separately. |
| 401 | `{"detail": "missing bearer token"}` | No credential presented |
| 401 | `{"detail": "invalid_api_key"}` | Key unknown, malformed, revoked, or expired |
| 403 | `{"detail": "insufficient_scope", "required": "write"}` | Key scope too low — the only error body with a second key |
| 403 | `{"detail": "cookie_auth_required"}` | Route is browser-only |
| 404 | `{"detail": "Not Found"}` | No such route |
| 404 | `{"detail": "invoice not found"}` | Object does not exist **or belongs to another tenant** |
| 404 | object — see below | Not found, on routes that use the object form |
| 405 | `{"detail": "Method Not Allowed"}` | Route exists, method does not |
| 409 | object — see below | Conflicting state |
| 413 | `{"detail": "request body too large"}` | Upload exceeded the body limit |
| 422 | list — see below | Request body failed validation |
| 422 | `{"detail": "Invalid request — check your input values."}` | A value passed schema validation but was rejected downstream |
| 422 | object — see below | Accounting-rule violation or plan gate |
| 429 | `{"detail": "rate limit exceeded"}` | See [Rate limits](#rate-limits) |
| 500 | `{"detail": "internal server error"}` | Unhandled server fault. Never contains a stack trace, exception type, or SQL. |
| 501 | `{"detail": "This feature is not yet available."}` | Endpoint exists but is not implemented |

Note that 422 arrives in **all three** shapes. The list form comes from schema
validation and names the offending field. The string form comes from a
`ValueError` raised after the schema passed — it names nothing. The object form
comes from an accounting-rule violation. Never assume 422 carries field detail.

### List form (422)

```bash
curl -s -X POST https://api.accountingorbit.com/api/v1/auth/register \
  -H 'Content-Type: application/json' -d '{"email":"not-an-email"}'
```
```json
{"detail":[{"type":"missing","loc":["body","password"],"msg":"Field required","input":{"email":"not-an-email"}}]}
```

`loc` is the path to the field: `["body", "password"]`, or
`["query", "limit"]` for a query parameter. `input` echoes what you sent —
never put a secret in a field you are willing to see in your own logs.

### Object form

Accounting-rule violations and plan gates return `detail` as an **object** with
exactly two keys, `message` and `code`:

```bash
# Void an invoice that is already void
curl -s -X POST https://api.accountingorbit.com/api/v1/invoices/37/void \
  -H 'Authorization: Bearer ao_...' -H 'Content-Type: application/json' -d '{}'
```
```json
{"detail": {"message": "invoice SINV-0001 is already void", "code": "ALREADY_RESOLVED"}}
```

```bash
# Post a journal entry whose debits and credits differ
curl -s -X POST https://api.accountingorbit.com/api/v1/journal-entries \
  -H 'Authorization: Bearer ao_...' -H 'Content-Type: application/json' \
  -d '{"entry_date":"2026-08-01","lines":[{"account_id":1,"debit":100,"credit":0},
                                          {"account_id":2,"debit":0,"credit":40}]}'
```
```json
{"detail": {"message": "invalid request", "code": "UNKNOWN"}}
```

This form is **not** tied to one status. It appears on **422** (rule violation),
**404** (object not found, on some routes) and **409** (conflicting state). So
status alone does not tell you which shape you got — always type-check.

#### `message`

Human-readable, English, and **not stable** — do not match on its text. Some
endpoints pass the real underlying reason (`"invoice SINV-0001 is already
void"`); others hardcode a generic placeholder and discard it. The unbalanced
entry above is the worst case: the most classifiable error in double-entry
bookkeeping reports only `"invalid request"`.

#### `code` is usually `"UNKNOWN"`

There is a defined set of codes:

```
UNBALANCED   PERIOD_CLOSED       INVALID_POSTING   ACCOUNT_NOT_FOUND
NOT_FOUND    INSUFFICIENT_STOCK  ALREADY_EXECUTED  ALREADY_RESOLVED
ALREADY_REVERSED   REFERENCE_INVALID
```

**But it is not reliably populated.** `code` defaults to the literal string
`"UNKNOWN"`, and in the current build roughly **three quarters of the errors
that use this form ship `"UNKNOWN"`** — including the unbalanced-entry example
above, where `UNBALANCED` exists and would have been correct.

A few endpoints also return codes outside that set and in a different case
(`not_found`, `invalid_transition`, `FX_RATE_INVALID`), so the list is not
closed and the casing is not uniform.

Practical guidance:

- Treat a non-`UNKNOWN` code as a useful hint, never as a guarantee.
- **Do not branch your control flow on `code` alone.** Branch on HTTP status
  first, and treat `code` as extra detail when it happens to be there.
- Compare case-insensitively if you must compare at all.
- `code == "UNKNOWN"` means "this failed a business rule and the server did not
  say which" — surface `message` to a human and stop.

### Parsing errors safely

Handle all three forms in one place, at the boundary:

```python
def problems(response):
    """-> list of (field, message). Never raises on a well-formed error body."""
    detail = response.json().get("detail")
    if isinstance(detail, list):          # schema validation
        return [(".".join(map(str, e.get("loc", []))), e.get("msg", "")) for e in detail]
    if isinstance(detail, dict):          # accounting rule / plan gate
        return [(detail.get("code", "UNKNOWN"), detail.get("message", ""))]
    return [("", detail if isinstance(detail, str) else str(detail))]
```

```javascript
function problems(body) {
  const d = body.detail;
  if (Array.isArray(d)) return d.map(e => [(e.loc || []).join("."), e.msg]);
  if (d && typeof d === "object") return [[d.code ?? "UNKNOWN", d.message ?? ""]];
  return [["", typeof d === "string" ? d : String(d)]];
}
```

The `Array.isArray` check must come first — a JSON array is also `typeof
"object"`, so checking for the object form first swallows validation errors.

## Idempotency

Orbit does not use an `Idempotency-Key` header. Document uploads are made
idempotent by **content fingerprint** instead, and this is a guarantee you can
build on, not an accident.

Every uploaded document is hashed. The hash is claimed in a partial unique
index scoped to your tenant, so exactly one upload of a given byte sequence
can ever be in the processed state. Re-uploading identical bytes is a
deliberate no-op: no second receipt, no second journal entry, no second OCR
charge.

**First upload:**

```bash
curl -X POST https://api.accountingorbit.com/api/v1/receipts/batch \
  -H 'Authorization: Bearer ao_...' \
  -F 'files=@receipt.png'
```
```json
{"total":1,"receipts":1,"duplicates":0,"errors":0,
 "files":[{"filename":"receipt.png","type":"receipt","merchant":"Staples Office Supply",
           "total":42.5,"receipt":{ "...": "..." },"receipt_id":309}]}
```

**Second upload, identical bytes:**

```json
{"total":1,"receipts":0,"duplicates":1,"errors":0,
 "files":[{"filename":"receipt.png","type":"duplicate","existing_filename":"receipt.png"}]}
```

What to rely on:

- The status is **`200`, not `409`**. A duplicate is a successful outcome, not
  a client error. Do not treat it as a failure and do not retry it.
- The per-file `type` is `"duplicate"`, and the batch counter `duplicates` is
  incremented instead of `receipts`.
- `existing_filename` names the file that already holds the fingerprint.
- The duplicate response carries **no `receipt_id`**. If your caller needs the
  id of the pre-existing receipt, find it via `GET /api/v1/receipts/pending`.
- Filename is irrelevant. The same bytes under a different name are still a
  duplicate; different bytes under the same name are not.
- The claim is **per tenant**. Tenant A uploading a file does not block tenant
  B from uploading the same file.
- The claim is atomic under concurrency. Two simultaneous uploads of the same
  bytes resolve to exactly one winner; the loser's provisional receipt row is
  removed and the loser is told `duplicate`.
- The claim is **released** when the receipt it belongs to is rejected or
  deleted, so a mistaken upload does not blacklist that file forever. Re-upload
  after a reject succeeds.

Non-upload mutations (creating an invoice, posting a payment) are **not**
idempotent. `POST /api/v1/invoices` twice creates two invoices. Deduplicate on
your side, or read back with a list call before writing.

## Pagination

List endpoints take `offset` and `limit` as query parameters and return an
envelope:

```bash
curl 'https://api.accountingorbit.com/api/v1/customers?offset=0&limit=50' \
  -H 'Authorization: Bearer ao_...'
```
```json
{"items":[],"total":0,"offset":0,"limit":200,"has_more":false}
```

- `items` — the page
- `total` — the full count matching the query, not the page size
- `offset` / `limit` — echoed back
- `has_more` — `true` when `offset + limit < total`

`limit` is clamped server-side to the range 1..1000 and `offset` to >= 0.
Out-of-range values are silently corrected, not rejected.

**The envelope reports what was applied, not what you asked for.** Send
`limit=99999` and the envelope comes back saying `"limit": 1000`, because 1000
is what ran. `has_more` is derived from the rows actually returned, so it is
safe to loop on:

| Sent | Rows returned (1 row exists) | Envelope reports |
|---|---|---|
| `limit=99999` | 1 | `"limit": 1000, "has_more": false` |
| `limit=0` | 1 | `"limit": 1, "has_more": false` |
| `offset=-5` | from row 0 | `"offset": 0` |

A short final page reports `has_more: false` even when `offset + limit` is still
below `total` — the flag tracks rows, not arithmetic.

Defaults differ per endpoint. Always send an explicit `limit`.

| Endpoint | `limit` default | `offset` | Envelope |
|---|---|---|---|
| `GET /api/v1/customers` | 200 | yes | `items` |
| `GET /api/v1/vendors` | 200 | yes | `items` |
| `GET /api/v1/invoices` | 200 | yes | `items` |
| `GET /api/v1/bills` | 100 | yes | `items` |
| `GET /api/v1/receipts/pending` | 200 | yes | `items` |
| `GET /api/v1/receipts/orphans` | 200 | yes | `items` |
| `GET /api/v1/bank/transactions` | 200 | yes | `items` |
| `GET /api/v1/bank/statements` | 100 | yes | `items` |
| `GET /api/v1/bank/orphans` | 200 | yes | `items` |
| `GET /api/v1/bank/connections` | 100 | yes | `items` |
| `GET /api/v1/bank/matched-pairs` | 100 | yes | `items` |
| `GET /api/v1/receipts` | 50 | yes | **`content`** |

### `GET /api/v1/receipts` names its array `content`

One endpoint deviates, in one respect: its top-level array key is **`content`**,
not `items`. Special-case that key. Everything else is standard — it takes
`offset`, honors `limit` up to the shared 1000 ceiling, and returns the full
`{total, offset, limit, has_more}` envelope:

```json
{"content": [ ... ], "total": 250, "offset": 200, "limit": 200, "has_more": false}
```

It also accepts `start` and `end` (both `YYYY-MM-DD`) and
`sort_by` / `sort_direction` for narrowing and ordering.

### Paging loop

### Paging loop

```python
offset, limit = 0, 200
while True:
    page = get(f"/customers?offset={offset}&limit={limit}").json()
    yield from page["items"]
    if not page["has_more"]:
        break
    offset += len(page["items"])
```

Advance by `len(page["items"])`, not by the `limit` you requested — if it was
clamped, the two differ. If you would rather not depend on `has_more` at all,
loop until `offset >= page["total"]`.

## Tenant scoping

Every object in Orbit belongs to exactly one tenant. The tenant is resolved
from the credential; there is no tenant parameter you can set.

**A reference to another tenant's object returns `404`, never `403`.** This is
intentional: a 403 would confirm the object exists, which is an enumeration
oracle. Verified live:

```
GET  /api/v1/parties/51   (own tenant)     -> 200
GET  /api/v1/parties/1    (other tenant)   -> 404 {"detail":"party not found"}
PATCH /api/v1/invoices/1  (other tenant)   -> 404 {"detail":"invoice not found"}
```

The rule holds for reads and writes alike. Practically: a `404` from Orbit
means "not yours or not there", and you cannot tell which. Do not build
retry-on-404 logic that assumes the object is merely lagging.

## Dates and timestamps

Two distinct formats. Do not conflate them.

**Business dates** — invoice date, due date, entry date, statement period,
report ranges — are plain calendar dates, `YYYY-MM-DD`, with no time and no
zone:

```json
{"date": "2026-07-15", "entry_date": "2026-07-15"}
```

They are tenant-local by intent: a receipt dated `2026-07-15` lands in the
July books regardless of where the caller is. Never attach a timezone to
these, and never convert them.

**Record timestamps** — `created_at`, `updated_at`, and similar audit fields —
are ISO-8601 with microseconds and **no timezone designator**:

```json
{"created_at": "2026-08-11T00:21:27.844612",
 "updated_at": "2026-08-11T00:21:27.844630"}
```

There is no trailing `Z` and no `+00:00`. **The values are UTC** — the example
above was produced by a request whose HTTP `Date` header read
`Tue, 11 Aug 2026 00:21:27 GMT` — but the wire format does not say so.

This matters, because a naive parser will localize them:

```python
from datetime import datetime, timezone
ts = datetime.fromisoformat(row["created_at"]).replace(tzinfo=timezone.utc)
```

```javascript
// WRONG: JS parses a bare ISO string as local time.
new Date("2026-08-11T00:21:27.844612")
// RIGHT:
new Date("2026-08-11T00:21:27.844612" + "Z")
```

Send timestamps the same way you receive them: ISO-8601, UTC, no designator.
Query-parameter date filters (`start`, `end`, `start_date`, `end_date`,
`date_from`, `date_to`, `as_of`) all take the plain `YYYY-MM-DD` form.

## Rate limits

> Contract for API keys. Verify against `X-RateLimit-Limit` on a live
> key-authed response before tuning a client to these numbers.

- 120 requests per rolling 60 seconds, **per key**. Not per tenant, not per IP.
- Over budget: `429` with `Retry-After: <seconds>`.
- Every key-authed response carries `X-RateLimit-Limit`,
  `X-RateLimit-Remaining`, and `X-RateLimit-Reset` (seconds until the window
  frees up).

The window is a sliding one, so `Retry-After` is the time until the oldest
request in your window ages out — not a fixed cooldown. Honor it exactly.
Do not retry a 429 on a fixed interval, and do not spread the same workload
across multiple keys to evade the limit.

Session-cookie traffic is limited separately and more tightly (100 per 60
seconds), which is another reason server-side integrations should use a key.

## Money

Monetary values appear as JSON strings with fixed scale where they come from a
decimal column (`"ytd_1099_amount": "0.00"`) and as JSON numbers where they are
computed for display (`"total": 42.5`). Parse both into a decimal type; never
into a binary float you intend to sum.

## Next

- [/docs/auth.md](/docs/auth.md) — API keys, scopes, revocation
- [/docs/api.md](/docs/api.md) — generated endpoint reference
- [/docs/index.md](/docs/index.md) — all doc pages
