Developer API
In one sentence
The KaiderBooks API is JSON over HTTPS. The current version lives under /api/v1, and protected requests use a JWT Bearer token; this chapter also documents the actual boundaries of API keys, webhooks and the settlement centre.
Current implementation is authoritative
Admin users can create API keys and assign scopes, but the current API guard only accepts Bearer JWTs. It does not read X-Api-Key or any other API-key header. The general-purpose business webhooks in section 4 also have no signature header; the POS webhook in section 9 is authenticated separately with a shared secret returned once. Until server-side support is added, do not use an API key as a request credential or treat an unsigned general-purpose webhook as a trusted instruction.
1. Core conventions
- Base URL:
https://books.chuhaisg.com/api/v1 - Content type: requests and responses use
application/jsonby default - Dates:
YYYY-MM-DD; timestamps are ISO 8601 with an offset - Money: every API JSON amount ending in
Centsis an integer number of cents; for example, S$12.34 is1234 - IDs: resource IDs are UUIDs
- Versioning: the major version is part of the path. The current version is
/v1; incompatible changes go into a new major path, whilev1only receives backward-compatible additions and corrections
Pagination and sorting
List endpoints that use standard pagination accept:
| Parameter | Convention |
|---|---|
page | Positive integer; default 1 |
pageSize | Positive integer; default 20, maximum 100 |
sort | String; sortable fields depend on the resource, with -field or field:desc commonly selecting descending order |
q | Text search on list endpoints that support it |
A typical paginated response is { "items": [...], "total": 42 }. Settlement-centre list endpoints currently return arrays and use resource-specific filters instead of standard pagination.
2. Authentication and sessions
Exchange credentials for a JWT
curl -X POST 'https://books.chuhaisg.com/api/v1/auth/login' \
-H 'Content-Type: application/json' \
-c cookies.txt \
-d '{"email":"demo@firm.sg","password":"YOUR_PASSWORD"}'A successful response contains accessToken, user, firm, role, clientId and firms. Send the access token with subsequent requests:
curl 'https://books.chuhaisg.com/api/v1/clients?page=1&pageSize=20' \
-H "Authorization: Bearer $ACCESS_TOKEN"Refresh-token rotation
Login also sets the refresh token in an HttpOnly refresh_token cookie. Its path is /api/v1/auth and its lifetime is seven days. A call to POST /auth/refresh must include that cookie, either through the browser or a cookie jar. In one transaction, the server revokes the old token, issues a replacement and rewrites the cookie; the old token cannot be reused. POST /auth/logout revokes the current refresh token and clears the cookie.
curl -X POST 'https://books.chuhaisg.com/api/v1/auth/refresh' \
-b cookies.txt -c cookies.txtAPI keys and scopes
A partner or manager can list, create and revoke API keys through /api/v1/api-keys, provided they have api-keys.manage. A full key starts with sk_live_ and appears only in the create response; only its hash and prefix are stored.
| Scope | Intended purpose |
|---|---|
read | Read resources and reports |
write | Create, update, post or match business resources |
filing | Submit filings |
The current release persists these scopes but does not connect API keys to the authentication guard, so there is no working API-key header yet. Every business request still requires a Bearer JWT.
3. Permission model
There are four internal firm roles: partner, manager, accountant and intern. A separate portal-facing client identity is constrained to the clientId on its membership; it is not a level in the internal firm hierarchy.
The permission matrix contains 26 permission keys. A request first passes the role gate, then the matrix entry for the current firm, role and permission key. The matrix can narrow a role's access; it cannot bypass the role gate. Common examples are:
| Scenario | Permission key | Notes |
|---|---|---|
| Post / reverse journals | journals.post / journals.reverse | The role must also be partner, manager or accountant |
| Export / view reports | reports.export / reports.view | Viewing and exporting are separate grants |
| Submit filings | filings.submit | This is a different layer from an API key's filing scope |
| View / manage settlements | settlements.view / settlements.manage | Settlement read and write endpoints use these two keys respectively |
| Manage webhooks | webhooks.manage | Admin endpoints also require partner or manager |
The remaining keys cover book editing, review, automation, bill approval, payroll, clients, users, roles, API keys, audit, invoices, payments, period locks, firm settings, compliance and corporate secretarial work. A missing key returns 403 FORBIDDEN, with a message such as Permission denied: settlements.manage.
4. Webhooks
Management endpoints and events
GET/POST /webhooks, PATCH/DELETE /webhooks/{id} and POST /webhooks/{id}/test require the partner or manager role and webhooks.manage.
Creation takes a url, at least one string in events, and an optional status (active or disabled). Event subscriptions are currently open strings rather than a fixed enum; invoice.sent is a common value in the Admin UI. The only fixed event that the code actually delivers is the test event, webhook.test.
curl -X POST 'https://books.chuhaisg.com/api/v1/webhooks' \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"url":"https://example.com/hooks/kaiderbooks","events":["invoice.sent"],"status":"active"}'
curl -X POST 'https://books.chuhaisg.com/api/v1/webhooks/WEBHOOK_UUID/test' \
-H "Authorization: Bearer $ACCESS_TOKEN"A test delivery POSTs id, event: "webhook.test", createdAt and firmId as JSON. Its headers are Content-Type: application/json and x-kaiderbooks-event: webhook.test, and it times out after five seconds. The API response contains delivered, status and statusCode.
Signature verification
The current implementation creates no secret and sends no signature or timestamp header, so there is no signature-verification procedure to implement yet. A receiver may use x-kaiderbooks-event for routing, but not as proof of origin. For now, use a dedicated HTTPS URL, network-level controls and idempotent processing, and wait for a server-side signing protocol before trusting business deliveries.
5. Endpoint index
Every path below is relative to https://books.chuhaisg.com/api/v1.
| Module | Base path | Main operations |
|---|---|---|
| auth | /auth | login, refresh, logout, me, switch-firm |
| clients | /clients | list, create, update and delete clients |
| journals | /journals | list/detail, draft CRUD, post, reverse |
| ar | /quotations, /invoices, /receipts | send/accept/convert quotes, send/void invoices, record/void receipts |
| ap | /bills, /payments | bill CRUD/approval/voiding, record/void payments |
| bank | /bank-accounts, /bank-transactions | bank-account CRUD, CSV import, match, unmatch and exclude |
| settlements | /outlets, /daily-closes, /platform-settlements, /settlement-rules | outlets, daily closes, platform import/post/match/reverse and commission rules |
| supplier-statements | /supplier-statements | supplier-statement CRUD, CSV import, automatic/manual matching, reports and reconciliation |
| recipes/stocktakes | /recipes, /stocktakes | recipe CRUD, theoretical margins, stocktake snapshot/count/completion |
| pos | /pos-connections, /pos/webhooks | POS-connection CRUD, receipt webhooks and daily-close sync |
| reports | /reports | trial balance, general ledger, P&L, balance sheet, cashflow, GST F5, daily profit and exports |
| filing | /filing-deadlines, /filing-records, /reminder-rules | deadlines, submission records and reminder rules |
| payroll | /payroll-runs | payroll-run and payslip CRUD, complete |
| compliance | /compliance | overview, risk assessments, beneficial owners, CDD, monitoring reviews and STR reports |
6. Settlement-centre endpoint reference
All settlement reads require settlements.view; writes require settlements.manage. Lists may omit clientId to cover the current firm. A client identity is always restricted to its own clientId. Every JSON amount below is an integer number of cents.
Outlets
| Method | Path | Key parameters | Response fields |
|---|---|---|---|
| GET | /outlets | query: clientId? | Array with id, clientId, code, name, address, active and audit fields |
| GET | /outlets/{id} | path: outlet UUID | One outlet |
| POST | /outlets | body: clientId, code, name, address?, active? | New outlet; code is unique within a client |
| PATCH | /outlets/{id} | body: at least one of code?, name?, address?, active? | Updated outlet |
| DELETE | /outlets/{id} | path: outlet UUID | { "id": "...", "deleted": true } |
Daily closes
Reconciliation equation: grossSalesCents = cashCents + netsCents + cardCents + deliveryCents + otherCents.
| Method | Path | Key parameters | Response fields |
|---|---|---|---|
| GET | /daily-closes | query: clientId?, outletId?, dateFrom?, dateTo?, status?; status=`draft | posted |
| GET | /daily-closes/{id} | path: daily-close UUID | id, clientId, outletId, closeDate, channel amounts, payoutCents, payoutNote, varianceCents, status, journalId, sourceFileId |
| POST | /daily-closes | body: clientId, outletId, closeDate, channel amounts; optional payoutCents, payoutNote, varianceCents, sourceFileId | New draft; an outlet/date pair must be unique |
| PATCH | /daily-closes/{id} | at least one mutable field; draft only | Updated daily close |
| DELETE | /daily-closes/{id} | draft only | { "id": "...", "deleted": true } |
| POST | /daily-closes/{id}/post | no body; reconciled, non-zero draft only | status: "posted" and generated journalId |
| POST | /daily-closes/{id}/reverse | no body; posted only | status: "reversed"; the linked journal is reversed too |
curl -X POST 'https://books.chuhaisg.com/api/v1/daily-closes' \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"clientId":"CLIENT_UUID","outletId":"OUTLET_UUID","closeDate":"2026-07-22","grossSalesCents":125000,"cashCents":30000,"netsCents":25000,"cardCents":40000,"deliveryCents":30000,"otherCents":0,"payoutCents":0}'Platform settlements
Reconciliation equation: netPayoutCents = grossCents - commissionCents - deliveryFeeCents - promoCents - refundCents + adjustmentCents.
| Method | Path | Key parameters | Response fields |
|---|---|---|---|
| GET | /platform-settlements | query: clientId?, channel?, status?; status=`draft | posted |
| GET | /platform-settlements/{id} | path: settlement UUID | id, clientId, outletId, channel, periodStart, periodEnd, all amounts, status, journalId, matchedBankTransactionId, sourceFileId, sourceJson |
| GET | /platform-settlements/{id}/check | path: settlement UUID | expectedCommissionCents, actualCommissionCents, varianceCents, ruleApplied; expected and variance are null when no active rule exists |
| POST | /platform-settlements | body: clientId, outletId?, channel, periodStart, periodEnd, all amounts and optional source fields | New draft |
| PATCH | /platform-settlements/{id} | at least one mutable field; draft only; dates and amounts are reconciled again | Updated settlement |
| DELETE | /platform-settlements/{id} | draft only | { "id": "...", "deleted": true } |
| POST | /platform-settlements/import | body: clientId, channel, template, csv, outletId?; template=`grab | foodpanda |
| POST | /platform-settlements/{id}/post | no body; reconciled draft only, with grossCents > 0 and netPayoutCents >= 0 | status: "posted" and journalId |
| POST | /platform-settlements/{id}/match | body: { "bankTransactionId": "UUID" }; transaction must belong to the client, be unmatched, and equal netPayoutCents | status: "matched" and matchedBankTransactionId |
| POST | /platform-settlements/{id}/unmatch | no body; matched only | Returns to status: "posted" and clears the bank match |
| POST | /platform-settlements/{id}/reverse | no body; unmatch first, then call on posted | status: "reversed"; the linked journal is reversed too |
The import body's csv is the complete CSV text. CSV monetary values use ordinary decimal notation and are converted to integer cents during import. Supply these three headers verbatim and in this order; Grab and Generic are currently identical:
| template | CSV header (verbatim) |
|---|---|
grab | Period Start,Period End,Gross Sales,Commission,Delivery Fee,Promotions,Refunds,Adjustments,Net Payout |
foodpanda | period_start,period_end,gross_amount,commission,delivery_fee,promo,refund,adjustment,payout |
generic | Period Start,Period End,Gross Sales,Commission,Delivery Fee,Promotions,Refunds,Adjustments,Net Payout |
curl -X POST 'https://books.chuhaisg.com/api/v1/platform-settlements/import' \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"clientId":"CLIENT_UUID","channel":"grab","template":"grab","csv":"Period Start,Period End,Gross Sales,Commission,Delivery Fee,Promotions,Refunds,Adjustments,Net Payout\n2026-07-01,2026-07-07,1000.00,200.00,20.00,30.00,0.00,0.00,750.00"}'Settlement rules
commissionBps is in basis points: 100 means 1%. tiersJson defines progressive tiers: each tier applies its bps only to the portion of gross between the preceding limit and its uptoCents; the final tier must have uptoCents: null. The tier results are summed and rounded to the nearest cent. Without tiers, the calculation is grossCents × commissionBps / 10000. The final expected commission is max(tiered or flat-rate commission, minGuaranteeCents).
GET /platform-settlements/{id}/check uses the active rule for the same client and channel and returns the actual commission plus varianceCents = actualCommissionCents - expectedCommissionCents. During import, an item gains a warning when commission differs from the active rule's expectation by more than 1% of expected commission.
| Method | Path | Key parameters | Response fields |
|---|---|---|---|
| GET | /settlement-rules | query: clientId? | Array of rules |
| GET | /settlement-rules/{id} | path: rule UUID | id, clientId, channel, name, commissionBps, minGuaranteeCents, tiersJson, active and audit fields |
| POST | /settlement-rules | body: clientId, channel, name, commissionBps?, minGuaranteeCents?, tiersJson?, active? | New rule; only one active rule per client/channel |
| PATCH | /settlement-rules/{id} | at least one mutable field; minGuaranteeCents and tiersJson may be null | Updated rule |
| DELETE | /settlement-rules/{id} | path: rule UUID | { "id": "...", "deleted": true } |
Daily profit report
| Method | Path | Key parameters | Response fields |
|---|---|---|---|
| GET | /reports/daily-profit | query: required clientId; choose either date, or the pair dateFrom + dateTo | A single day has date; a range has range and trend. Both have total, outlets, unassigned; each bucket contains grossSalesCents, netSalesCents, gstCents, channels, deliverySharePct, platformCostCents, payoutCents, laborCents, netProfitCents |
curl 'https://books.chuhaisg.com/api/v1/reports/daily-profit?clientId=CLIENT_UUID&date=2026-07-22' \
-H "Authorization: Bearer $ACCESS_TOKEN"7. Supplier reconciliation
A supplier statement starts as draft and becomes reconciled after every line is cleared. Read endpoints follow the current actor's client scope; writes, matching and reconciliation require a partner, manager or accountant. JSON monetary fields remain integer cents.
| Method | Path | Key parameters | Semantics / response |
|---|---|---|---|
| GET | /supplier-statements | query: clientId?, contactId?, status?; status=`draft | reconciled` |
| GET | /supplier-statements/{id} | path: statement UUID | One statement |
| POST | /supplier-statements | body: clientId, contactId, statementDate, periodStart, periodEnd, totalCents; optional source fields | New draft statement |
| PATCH | /supplier-statements/{id} | at least one mutable field; draft only, and status changes only through reconcile | Updated statement |
| DELETE | /supplier-statements/{id} | draft only | Deletes the statement and returns { "id": "...", "deleted": true } |
| POST | /supplier-statements/import | body: clientId, contactId, statementDate, periodStart, periodEnd, csv | Creates a draft and valid lines; returns { statement, errors: [{row, error}] } |
| POST | /supplier-statements/{id}/auto-match | no body; draft only | Updated lines; matches non-draft bills by reference |
| GET | /supplier-statements/{id}/report | path: statement UUID | Four buckets, line details, billsNotOnStatement, statementTotalCents, matchedTotalCents |
| PATCH | /supplier-statements/{id}/lines/{lineId} | body: billId (UUID or null) and note?; draft only | Manually matches or clears a match; confirming a different amount requires a note |
| POST | /supplier-statements/{id}/reconcile | no body; draft only | Changes status to reconciled only when every line is matched; otherwise returns 400 |
The import CSV's first line must be Date,Doc No,Amount in that order. Dates use the formats accepted by the importer, and ordinary decimal monetary values are converted to integer cents. Valid rows are saved while rejected rows appear in errors. auto-match first compares a line's docRef with supplierRef on a non-draft bill for the same client and supplier, then falls back to billNo. A bill cannot be claimed by multiple lines or another statement. Equal amounts produce matched, a matching reference with a different amount produces amountMismatch, and no available bill produces missingBill.
The report's four buckets are matched, amountMismatch, missingBill and unmatched, each with count and amountCents. billsNotOnStatement separately lists non-draft bills in the statement period that this statement has not claimed. reconcile does not post a journal or mutate a bill; it records that every statement line is cleared and locks the statement in reconciled state.
curl -X POST 'https://books.chuhaisg.com/api/v1/supplier-statements/import' \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"clientId":"CLIENT_UUID","contactId":"SUPPLIER_UUID","statementDate":"2026-07-31","periodStart":"2026-07-01","periodEnd":"2026-07-31","csv":"Date,Doc No,Amount\n2026-07-10,SUP-INV-42,125.50"}'bills.supplierRef
A bill's supplierRef is the reference printed on the supplier's document, distinct from the KaiderBooks-generated billNo. Create and update requests may include supplierRef (optional, nullable, maximum 64 characters). Preserve the supplier invoice number verbatim where possible because automatic reconciliation prefers it; billNo is only the fallback key.
8. Costing and stocktakes
Recipes and theoretical margins
| Method | Path | Key parameters | Semantics / response |
|---|---|---|---|
| GET | /recipes | query: clientId?, active? | Array of recipes |
| GET | /recipes/margins | query: required clientId | sellPriceCents, theoreticalCostCents, marginCents, marginPct for each recipe |
| GET | /recipes/{id} | path: recipe UUID | Recipe with components |
| POST | /recipes | body: clientId, name, components; optional menuCode, sellPriceCents, active | New recipe; each component is { inventoryItemId, qty } |
| PATCH | /recipes/{id} | at least one mutable field; may replace all components | Updated recipe |
| DELETE | /recipes/{id} | path: recipe UUID | { "id": "...", "deleted": true } |
qty is a positive string with at most four decimal places, and an inventory item may appear only once in a recipe. GET /recipes/margins calculates qty × inventoryItem.costCents for each component, rounds each line to integer cents, then sums the lines into theoreticalCostCents. It then calculates marginCents = sellPriceCents - theoreticalCostCents and marginPct = marginCents / sellPriceCents × 100, rounded to one decimal place. Both margin fields are null without a selling price; the percentage is null when the price is zero.
Stocktake workflow
| Method | Path | Key parameters | Semantics / response |
|---|---|---|---|
| GET | /stocktakes | query: clientId?, outletId?, status?; status=`draft | completed` |
| GET | /stocktakes/{id} | path: stocktake UUID | Stocktake with its lines |
| POST | /stocktakes | body: clientId, takenAt, outletId?, note? | Creates a draft and snapshots every active client inventory item and its current stockQty into line systemQty |
| PATCH | /stocktakes/{id}/lines/{lineId} | body: { "countedQty": "12.5000" }; draft only | Records the physical count for one line |
| POST | /stocktakes/{id}/complete | no body; draft only | Completes the stocktake, synchronises inventory, and posts a journal when a variance exists |
| DELETE | /stocktakes/{id} | draft only | { "id": "...", "deleted": true } |
At completion, a line without countedQty uses its systemQty. The API calculates (countedQty - systemQty) × costCents using the inventory item's cost at completion, rounds to cents, stores unitCostCents and varianceCents, and sets the inventory item's stockQty to the counted quantity. A loss debits 5010 and credits 1310; a gain reverses those entries by debiting 1310 and crediting 5010. With no monetary variance, no journal is created and journalId is null.
9. POS connections and webhooks
POS-connection reads require settlements.view; writes and sync-day require settlements.manage. Providers are loyverse|epos|ichef|qashier|storehub, and statuses are pending|active|disabled.
| Method | Path | Key parameters | Semantics / response |
|---|---|---|---|
| GET | /pos-connections | query: clientId?, outletId?, provider?, status? | Array of connections; no secret is returned |
| GET | /pos-connections/{id} | path: connection UUID | One connection with webhookPath; no secret is returned |
| POST | /pos-connections | body: clientId, provider, outletId?, status?, config? | New connection; the response additionally returns the 48-character webhookSecret once |
| PATCH | /pos-connections/{id} | at least one of provider?, outletId?, status?, config? | Updated connection; the secret cannot be read or rotated |
| DELETE | /pos-connections/{id} | path: connection UUID | Soft-deletes and returns { "id": "...", "deleted": true } |
| POST | /pos-connections/{id}/sync-day | body: { "date": "YYYY-MM-DD" }; connection must have an outlet | Aggregates unprocessed receipts into a new or updated draft daily close; returns dailyCloseId, eventsProcessed, grossCents, channels |
| POST | /pos/webhooks/{connectionId} | header: x-webhook-secret; body: JSON object | Public receiver; success returns { "accepted": true, "eventId": "UUID" } |
Store webhookSecret securely as soon as the connection is created; list, get and update responses all remove it. The POS sends it as x-webhook-secret to https://books.chuhaisg.com/api/v1/pos/webhooks/{connectionId}. A missing connection, disabled connection, missing header or incorrect secret all return the same 401 UNAUTHORIZED with Invalid webhook credentials, so connection existence is not disclosed.
Example receipt payload (type defaults to receipt when omitted):
{
"type": "receipt",
"receipt_date": "2026-07-22T18:30:00+08:00",
"total_money": "125.50",
"payments": [
{ "name": "CASH", "money_amount": "25.50" },
{ "type": "CARD", "money_amount": "100.00" }
]
}Here total_money and money_amount are ordinary decimal POS amounts rather than Cents fields; the server converts them to integer cents. sync-day only processes unprocessed eventType: "receipt" events whose receipt_date (falling back to created_at) matches the requested date. Each payment uses name, falling back to type, as a key in the connection's config.paymentMap. A mapping target must be cash|nets|card|delivery|other; an absent or invalid mapping goes to other. The API separately sums receipt total_money and the five channels and returns 422 unless they agree. On success it creates or updates the outlet/date draft daily close and marks the events processed.
curl -X POST 'https://books.chuhaisg.com/api/v1/pos/webhooks/CONNECTION_UUID' \
-H 'Content-Type: application/json' \
-H 'x-webhook-secret: YOUR_WEBHOOK_SECRET' \
-d '{"type":"receipt","receipt_date":"2026-07-22","total_money":"125.50","payments":[{"name":"CASH","money_amount":"25.50"},{"name":"CARD","money_amount":"100.00"}]}'10. Error handling
Every error is wrapped in error; validation failures may also contain details:
{
"error": {
"code": "BAD_REQUEST",
"message": "Validation failed",
"details": {}
}
}| HTTP | code | Meaning |
|---|---|---|
| 400 | BAD_REQUEST | Schema validation, invalid date range or state, or failed daily-close/platform-settlement reconciliation |
| 401 | UNAUTHORIZED | Missing Bearer token, invalid/expired access token, or missing/invalid refresh token |
| 403 | FORBIDDEN | Role gate, client data scope or permission key denied the request; the message normally identifies the required key |
| 404 | NOT_FOUND | The resource does not exist within the current firm/client scope |
| 409 | CONFLICT | Unique conflict, such as a duplicate outlet/date close, outlet code or active rule |
Branch on the HTTP status and error.code. The human-readable message is useful for diagnosis but is not a stable machine discriminator.