Conventions
The rules every endpoint follows — read once, rely on everywhere.
The rules that hold everywhere. Learn them once; every surface obeys them.
Identity and shape
- UUIDs only. Every resource id is a UUID, stable for the resource's lifetime.
General-ledger lines use a stable text id (
lineId) that survives re-imports. - JSON in, JSON out (
application/json); errors are RFC 9457application/problem+json(see the error catalog). - One casing rule. JSON fields, query parameters and path parameters are
camelCase(pageSize,reconciliationStatus,nextCursor,{companyId}). Problemreasonvalues anderrorcodes aresnake_case(job_in_flight,validation_failed); scope names areresource:action(ledger:read); header names areHttp-Case(Idempotency-Key,X-Request-Id). - Money is decimal. Amounts you read are JSON numbers with explicit decimals,
signed where the domain is signed (bank debits negative). Parse as decimal, never
float. Amounts you send (imports) are documented as decimal
strings (
"-1250.00", at most four decimals); JSON numbers are accepted too, but the string form is the safer choice from languages without a decimal type. - Dates are ISO 8601 (
2026-03-01); timestamps are UTC ISO 8601 instants. - Actions are
POST …/actions/<verb>:/actions/disable,/actions/enable,/actions/restore,/actions/archive,/actions/complete,/actions/rotate-secret. A delete isDELETEon the resource; where it takes an audit note (DELETE /companies/{companyId}), that is the optional?reason=query parameter — deletes carry no body. - Request bodies are capped at 8 MiB; larger bodies are refused with
413 payload_too_largebefore they are parsed.
Reach
Your token defines your reach: an API client sees its organization and the organizations below it. Anything outside your reach is a 404 — never a 403 — whether it exists or not. Resource ids are not guessable capabilities.
Settings writes
Company settings (settings:write) change as patches: a field you leave out stays
as it is, and reset (where an operation has one) clears a value or hands it back to
its default. Each settings read returns the version it shows — version,
companyRoutineVersion or an account's settingsRevision — and each write takes it
back as expectedVersion or settingsRevision. If the settings changed in between, the
write is 409 conflict with the currentVersion: read them again and re-apply your
change to what is there now, rather than retrying with the new version.
Pagination
Lists return:
{ "data": [ ... ], "pagination": { "nextCursor": "…", "hasMore": true } }
Follow nextCursor until it is null (it is nullable, and hasMore is false on
the last page). Cursors are opaque, signed, and bound to your client and the exact
filters — reuse with changed filters is invalid_cursor (restart from page one).
pageSize accepts 1–200, default 50; a value outside that range is refused with
400 validation_failed (it is not clamped). Small bounded lists (chart of accounts,
VAT terms, users) return everything with a null cursor.
Idempotency
Every mutation requires an Idempotency-Key header (any unique string; use a UUID
per logical action). Retrying with the same key returns the stored response,
byte-identical, flagged with Idempotency-Replayed: true. The same key with a
different body is idempotency_key_reused (422). This makes timeouts safe: when in
doubt, retry with the same key.
Keys are scoped per API client and per operation: the same key on two different
operations is two different keys, and another client can never replay yours. Stored
responses are replayed for 24 hours; a retry after that window executes the
operation again, as a new request. One consequence worth knowing: a replayed
create_document_upload returns the signed upload URL as it was stored, which may
already have expired — on an expired upload URL, start a new upload with a new key.
When you PUT the file to that URL, send every header in upload.headers, exactly
as given: Content-Type, x-goog-content-length-range (the body may not be larger than
the sizeBytes you declared) and x-goog-if-generation-match: 0 (the URL creates the
file once; it cannot replace it). They are part of the signature, so storage refuses a
PUT without them. To send a different file, start a new upload.
400 or 422?
The two validation statuses split on shape versus meaning:
400 validation_failed— the request is malformed. Something cannot be parsed, is missing, or is outside the shape the endpoint accepts:pageSize=500, aperiodof 13, a missing required field, an unparseable date. Fix the request.422 unprocessable— the request is well-formed but semantically refused. Every field parses, and a business rule says no: an account code missing from that year's chart (reason: unknown_account_codes), a lineidcontaining a forbidden character, a role that does not exist, a company on a rule that belongs to another organization. Fix the data, not the syntax.
Both carry the same errors[] item shape ({field, message, index?, allowedValues?})
— see the error catalog. 409 conflict is
neither: the request is fine, the state is not.
Async work
Heavy operations return 202 Accepted with the resource to poll — for imports and
corrections, the import itself (GET /v1/companies/{companyId}/imports/{importId}).
That resource names its jobId, which is also pollable at GET /v1/jobs/{jobId} and
listed by GET /v1/jobs (the jobs this API client submitted, newest first, optional
status filter). Poll until the status is terminal, or subscribe to
job.succeeded/job.failed webhooks. One job per (type, scope) is in
flight at a time — a duplicate submission is a 409 whose jobId extension names the
blocking job.
Rate limits
Per-client quotas by plan: standard 300 requests/min (burst 60), premium
1200/min (burst 240). 429 responses carry Retry-After; every response carries
RateLimit-Limit and RateLimit-Remaining. Design for backoff; for bulk reads prefer
a larger pageSize over more requests.
Honest note on precision: the plan quota is enforced per API pod, without a shared
counter. When the API runs on more than one replica, the effective quota is the plan
figure multiplied by the replica count, and RateLimit-Remaining describes the pod
that answered. Treat the published figures as the guaranteed minimum, not a hard
ceiling; the gateway's volumetric limit above the plan tiers is what actually stops
floods.
Versioning and deprecation
The API is /v1 and — from GA — additive-only: new fields, endpoints, and event types may
appear at any time — build tolerant readers. Anything scheduled for removal is
announced in the changelog, marked with Deprecation and
Sunset response headers, and kept working for at least 12 months after the
sunset announcement (the full policy: deprecation-policy).
Request tracing
Every response carries an X-Request-Id header. Problem documents repeat the same
value as the requestId property; successful responses carry it only in the header.
If you send your own X-Request-Id on a request, it is honoured and echoed back, so
you can correlate against your own logs. Include it in support requests — it links
directly to our logs and audit trail.
Response headers
| Header | On | Meaning |
|---|---|---|
X-Request-Id |
every response | correlation id (see above) |
RateLimit-Limit, RateLimit-Remaining |
every response | the plan quota and what is left of it on the answering pod |
Retry-After |
429, 503, 409 idempotency_in_flight |
seconds to wait |
Idempotency-Replayed |
replayed mutations | true when the body is a stored response |
Deprecation, Sunset |
deprecated operations | see the deprecation policy |
WWW-Authenticate |
401, 403 insufficient_scope |
RFC 6750 challenge; names the missing scope |