Builder's guide · Section 7

The rules every request follows

The API is large (over 400 routes) but uniform. Learn these rules once and every domain behaves the same way.

Headers

HeaderWhenNotes
X-Source-SystemEvery requestYour writer's code. It must match the key.
Authorization: BearerEvery requestYour writer's key. Never log it.
Idempotency-KeyEvery POST, PUT, PATCH, DELETE1–255 printable characters; a UUID v4 per user action.
X-Request-Id (reply)Every replyRecord it with the person who asked (section 8).
Idempotent-Replayed (reply)Replayed writestrue when the reply is the stored result of an earlier attempt.

Money is a string

Every amount is a JSON string with exactly two decimals: "1234.50". Never 1234.5 (a number), never "1,234.50", never negative; direction comes from the field (debit_amount / credit_amount). Quantities and unit prices are strings too, at their own precision. In your code, keep amounts as strings or decimals (for example with decimal.js), and format them only for display. Never convert an amount to a floating-point number, because a double cannot hold every cent.

One error shape

HTTP/1.1 422 Unprocessable Entity
{ "error": {
    "code": "PERIOD_CLOSED",
    "message": "Posting date 2026-08-31 falls in fiscal period 2026-08, which is closed.",
    "details": [ { "code": "PERIOD_CLOSED", "field": "posting_date",
                   "message": "Period 2026-08 is closed." } ],
    "request_id": "req_01J9Z6Q4N8" } }
  • Branch on code, which never changes meaning. Show message to people, but never parse it.
  • A validation failure lists every problem in details, each with its field path (lines[1].debit_amount). Mark each field on the form.
  • A 4xx is an answer about the books. Show it and do not retry. Only a 5xx or a network failure is retried, with the same idempotency key.
  • Make your own server's errors use the same envelope, so the screens handle one shape.

Idempotency: one key per user action

Create the key when the person clicks “Post”, not for each HTTP attempt. If the network drops and you retry, GAAPcore returns the first result instead of posting twice. Two traps:

  • After a refusal, use a new key. GAAPcore stores refusals too. If the user fixes the cause and you resend with the old key, you get the old refusal back. Keep a key only while the outcome is unknown.
  • A changed request is a new action. The same key with a different body is refused (409 IDEMPOTENCY_KEY_REUSED).

For scripts and imports, derive the key from the content (for example, the source file name and line number), so re-running the script is safe.

Companies, lists and numbers

  • company_id is always explicit: in the body of every create, and as a query parameter on company reads. GAAPcore never infers it. Put the company in your URLs (/c/<company>/…) and in every cache key, so switching company never shows another company's data.
  • Lists are paged: { data, next_cursor }, at most 200 rows a page. Follow next_cursor until it is null.
  • Document numbers come from GAAPcore. When a number series exists for a document type, don't send a number: GAAPcore assigns the next one and refuses yours. Show the expected next number as information only.
  • Warnings. A successful write can carry warnings (for example, deactivating an account that still has a balance). Show them after the write.

Pin the version; generate the client

Keep a copy of the openapi.yaml you built against, with its commit and schema version (the reference console keeps them in vendor/accountingkit/PIN.json). Generate types and a typed client from it, for example with openapi-typescript and openapi-fetch, so a renamed field fails the compiler, not a customer demo. At run time, compare your pin with GET /v1/version and show a banner when they differ.