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
| Header | When | Notes |
|---|---|---|
X-Source-System | Every request | Your writer's code. It must match the key. |
Authorization: Bearer | Every request | Your writer's key. Never log it. |
Idempotency-Key | Every POST, PUT, PATCH, DELETE | 1–255 printable characters; a UUID v4 per user action. |
X-Request-Id (reply) | Every reply | Record it with the person who asked (section 8). |
Idempotent-Replayed (reply) | Replayed writes | true 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. Showmessageto 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_idis 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. Follownext_cursoruntil it isnull. - 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.