Skip to content
手册 v0.1.0 · KaiderBooks v2.0.0 · 2026-07-22

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/json by default
  • Dates: YYYY-MM-DD; timestamps are ISO 8601 with an offset
  • Money: every API JSON amount ending in Cents is an integer number of cents; for example, S$12.34 is 1234
  • 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, while v1 only receives backward-compatible additions and corrections

Pagination and sorting

List endpoints that use standard pagination accept:

ParameterConvention
pagePositive integer; default 1
pageSizePositive integer; default 20, maximum 100
sortString; sortable fields depend on the resource, with -field or field:desc commonly selecting descending order
qText 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

bash
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:

bash
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.

bash
curl -X POST 'https://books.chuhaisg.com/api/v1/auth/refresh' \
  -b cookies.txt -c cookies.txt

API 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.

ScopeIntended purpose
readRead resources and reports
writeCreate, update, post or match business resources
filingSubmit 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:

ScenarioPermission keyNotes
Post / reverse journalsjournals.post / journals.reverseThe role must also be partner, manager or accountant
Export / view reportsreports.export / reports.viewViewing and exporting are separate grants
Submit filingsfilings.submitThis is a different layer from an API key's filing scope
View / manage settlementssettlements.view / settlements.manageSettlement read and write endpoints use these two keys respectively
Manage webhookswebhooks.manageAdmin 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.

bash
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.

ModuleBase pathMain operations
auth/authlogin, refresh, logout, me, switch-firm
clients/clientslist, create, update and delete clients
journals/journalslist/detail, draft CRUD, post, reverse
ar/quotations, /invoices, /receiptssend/accept/convert quotes, send/void invoices, record/void receipts
ap/bills, /paymentsbill CRUD/approval/voiding, record/void payments
bank/bank-accounts, /bank-transactionsbank-account CRUD, CSV import, match, unmatch and exclude
settlements/outlets, /daily-closes, /platform-settlements, /settlement-rulesoutlets, daily closes, platform import/post/match/reverse and commission rules
supplier-statements/supplier-statementssupplier-statement CRUD, CSV import, automatic/manual matching, reports and reconciliation
recipes/stocktakes/recipes, /stocktakesrecipe CRUD, theoretical margins, stocktake snapshot/count/completion
pos/pos-connections, /pos/webhooksPOS-connection CRUD, receipt webhooks and daily-close sync
reports/reportstrial balance, general ledger, P&L, balance sheet, cashflow, GST F5, daily profit and exports
filing/filing-deadlines, /filing-records, /reminder-rulesdeadlines, submission records and reminder rules
payroll/payroll-runspayroll-run and payslip CRUD, complete
compliance/complianceoverview, 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

MethodPathKey parametersResponse fields
GET/outletsquery: clientId?Array with id, clientId, code, name, address, active and audit fields
GET/outlets/{id}path: outlet UUIDOne outlet
POST/outletsbody: 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.

MethodPathKey parametersResponse fields
GET/daily-closesquery: clientId?, outletId?, dateFrom?, dateTo?, status?; status=`draftposted
GET/daily-closes/{id}path: daily-close UUIDid, clientId, outletId, closeDate, channel amounts, payoutCents, payoutNote, varianceCents, status, journalId, sourceFileId
POST/daily-closesbody: clientId, outletId, closeDate, channel amounts; optional payoutCents, payoutNote, varianceCents, sourceFileIdNew draft; an outlet/date pair must be unique
PATCH/daily-closes/{id}at least one mutable field; draft onlyUpdated daily close
DELETE/daily-closes/{id}draft only{ "id": "...", "deleted": true }
POST/daily-closes/{id}/postno body; reconciled, non-zero draft onlystatus: "posted" and generated journalId
POST/daily-closes/{id}/reverseno body; posted onlystatus: "reversed"; the linked journal is reversed too
bash
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.

MethodPathKey parametersResponse fields
GET/platform-settlementsquery: clientId?, channel?, status?; status=`draftposted
GET/platform-settlements/{id}path: settlement UUIDid, clientId, outletId, channel, periodStart, periodEnd, all amounts, status, journalId, matchedBankTransactionId, sourceFileId, sourceJson
GET/platform-settlements/{id}/checkpath: settlement UUIDexpectedCommissionCents, actualCommissionCents, varianceCents, ruleApplied; expected and variance are null when no active rule exists
POST/platform-settlementsbody: clientId, outletId?, channel, periodStart, periodEnd, all amounts and optional source fieldsNew draft
PATCH/platform-settlements/{id}at least one mutable field; draft only; dates and amounts are reconciled againUpdated settlement
DELETE/platform-settlements/{id}draft only{ "id": "...", "deleted": true }
POST/platform-settlements/importbody: clientId, channel, template, csv, outletId?; template=`grabfoodpanda
POST/platform-settlements/{id}/postno body; reconciled draft only, with grossCents > 0 and netPayoutCents >= 0status: "posted" and journalId
POST/platform-settlements/{id}/matchbody: { "bankTransactionId": "UUID" }; transaction must belong to the client, be unmatched, and equal netPayoutCentsstatus: "matched" and matchedBankTransactionId
POST/platform-settlements/{id}/unmatchno body; matched onlyReturns to status: "posted" and clears the bank match
POST/platform-settlements/{id}/reverseno body; unmatch first, then call on postedstatus: "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:

templateCSV header (verbatim)
grabPeriod Start,Period End,Gross Sales,Commission,Delivery Fee,Promotions,Refunds,Adjustments,Net Payout
foodpandaperiod_start,period_end,gross_amount,commission,delivery_fee,promo,refund,adjustment,payout
genericPeriod Start,Period End,Gross Sales,Commission,Delivery Fee,Promotions,Refunds,Adjustments,Net Payout
bash
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.

MethodPathKey parametersResponse fields
GET/settlement-rulesquery: clientId?Array of rules
GET/settlement-rules/{id}path: rule UUIDid, clientId, channel, name, commissionBps, minGuaranteeCents, tiersJson, active and audit fields
POST/settlement-rulesbody: 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 nullUpdated rule
DELETE/settlement-rules/{id}path: rule UUID{ "id": "...", "deleted": true }

Daily profit report

MethodPathKey parametersResponse fields
GET/reports/daily-profitquery: required clientId; choose either date, or the pair dateFrom + dateToA 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
bash
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.

MethodPathKey parametersSemantics / response
GET/supplier-statementsquery: clientId?, contactId?, status?; status=`draftreconciled`
GET/supplier-statements/{id}path: statement UUIDOne statement
POST/supplier-statementsbody: clientId, contactId, statementDate, periodStart, periodEnd, totalCents; optional source fieldsNew draft statement
PATCH/supplier-statements/{id}at least one mutable field; draft only, and status changes only through reconcileUpdated statement
DELETE/supplier-statements/{id}draft onlyDeletes the statement and returns { "id": "...", "deleted": true }
POST/supplier-statements/importbody: clientId, contactId, statementDate, periodStart, periodEnd, csvCreates a draft and valid lines; returns { statement, errors: [{row, error}] }
POST/supplier-statements/{id}/auto-matchno body; draft onlyUpdated lines; matches non-draft bills by reference
GET/supplier-statements/{id}/reportpath: statement UUIDFour buckets, line details, billsNotOnStatement, statementTotalCents, matchedTotalCents
PATCH/supplier-statements/{id}/lines/{lineId}body: billId (UUID or null) and note?; draft onlyManually matches or clears a match; confirming a different amount requires a note
POST/supplier-statements/{id}/reconcileno body; draft onlyChanges 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.

bash
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

MethodPathKey parametersSemantics / response
GET/recipesquery: clientId?, active?Array of recipes
GET/recipes/marginsquery: required clientIdsellPriceCents, theoreticalCostCents, marginCents, marginPct for each recipe
GET/recipes/{id}path: recipe UUIDRecipe with components
POST/recipesbody: clientId, name, components; optional menuCode, sellPriceCents, activeNew recipe; each component is { inventoryItemId, qty }
PATCH/recipes/{id}at least one mutable field; may replace all componentsUpdated 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

MethodPathKey parametersSemantics / response
GET/stocktakesquery: clientId?, outletId?, status?; status=`draftcompleted`
GET/stocktakes/{id}path: stocktake UUIDStocktake with its lines
POST/stocktakesbody: 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 onlyRecords the physical count for one line
POST/stocktakes/{id}/completeno body; draft onlyCompletes 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.

MethodPathKey parametersSemantics / response
GET/pos-connectionsquery: clientId?, outletId?, provider?, status?Array of connections; no secret is returned
GET/pos-connections/{id}path: connection UUIDOne connection with webhookPath; no secret is returned
POST/pos-connectionsbody: 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 UUIDSoft-deletes and returns { "id": "...", "deleted": true }
POST/pos-connections/{id}/sync-daybody: { "date": "YYYY-MM-DD" }; connection must have an outletAggregates 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 objectPublic 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):

json
{
  "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.

bash
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:

json
{
  "error": {
    "code": "BAD_REQUEST",
    "message": "Validation failed",
    "details": {}
  }
}
HTTPcodeMeaning
400BAD_REQUESTSchema validation, invalid date range or state, or failed daily-close/platform-settlement reconciliation
401UNAUTHORIZEDMissing Bearer token, invalid/expired access token, or missing/invalid refresh token
403FORBIDDENRole gate, client data scope or permission key denied the request; the message normally identifies the required key
404NOT_FOUNDThe resource does not exist within the current firm/client scope
409CONFLICTUnique 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.


Related: API keys and webhooks in Administration →