Build an integration¶
A walkthrough of building a third-party application that talks to SAE Books on behalf of a user — the canonical "I have a SaaS and I want to read/write my customer's books" case.
Choose your auth model¶
| You are | Auth model |
|---|---|
| A line-of-business app run by the user (or their bookkeeper) | JWT bearer, obtained from POST /api/v1/auth/login with the user's credentials |
| A backend automation that doesn't act on behalf of any user | API token — issue a named token via POST /api/v1/api-tokens (requires a prior JWT login); use it as a long-lived Authorization: Bearer credential |
| A self-hosted instance fronted by Discourse, Authentik, or CF Access | OAuth handoff — SAE Books web handles the SSO callback and mints a JWT internally |
No public OAuth consent flow in v0.6
SAE Books does not yet expose a public OAuth2 authorisation endpoint (/oauth/authorize + consent screen) for third-party SaaS developers. The /auth/oauth-handoff endpoint is an internal server-to-server path used by the web layer after a Discourse/Authentik/CF Access callback — it is not intended for, and will reject, arbitrary third-party clients. A public OAuth surface is on the roadmap. For now, integrations authenticate via JWT (/api/v1/auth/login) or a named API token.
Token lifecycle¶
- JWT access tokens (from
POST /api/v1/auth/login) last 8 hours by default. - Call
POST /api/v1/auth/refreshwith a still-valid token to get a fresh one. Refresh proactively — before the token expires rather than catching the 401 and retrying. - Named API tokens (from
POST /api/v1/api-tokens) are long-lived. Setttl_daysat creation (1–3650) or omit for no expiry. Revoke viaDELETE /api/v1/api-tokens/{id}. - On a
401, do not retry indefinitely — the token may be expired or revoked. Re-authenticate or return the user to the connect flow.
Idempotency¶
Every write that creates a journal, payment, contact, or document
accepts X-Idempotency-Key: <uuid>. Generate a fresh UUID per logical
operation and store it alongside the operation in your own database.
On retry, replay with the same key — the server returns the original
response, you don't double-post.
This matters because agentic retry loops are common. If your
worker crashes between sending a POST /journals and persisting the
returned journal ID, you'll retry on restart. Without an idempotency
key, you've now posted the same journal twice. With one, you get the
original response and continue.
Webhooks¶
Outbound webhook registration not yet available
SAE Books v0.6 does not yet expose a POST /api/v1/webhooks registration endpoint for third-party integrations to receive event pushes. The webhook infrastructure described below reflects the intended design; use polling against the REST API in the meantime.
For event-driven integrations, the planned approach is to register a webhook target:
curl -X POST "$SAEBOOKS_API_BASE/webhooks" \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.example.com/webhooks/saebooks",
"events": ["journal.posted", "bank_transaction.received", "bas.prepared"],
"secret": "<random-32-bytes>"
}'
Every delivery will include:
X-SAEBooks-Signature: t=<unix-ts>,v1=<hmac-sha256>— verify with your secret. The signed payload is<unix-ts>.<request-body>.X-SAEBooks-Delivery: <uuid>— for idempotent processing on your side; SAE Books retries on non-2xx.
Pagination¶
List endpoints use one of two patterns depending on the resource:
Page-based (invoices, bills, contacts, and most sub-ledger objects):
Response: {items: [...], total: N, limit: 100, offset: 0}. Advance by incrementing page.
Cursor-based (cashbook, change log, and a small number of streaming endpoints):
Response: {items: [...], next_cursor: "..."}. next_cursor: null means no more pages.
Consult the API reference for each endpoint's specific pagination style.
Rate limits¶
/api/v1/* does not impose application-level rate limits in v0.6 — enforce limits at your
reverse proxy (Caddy's rate_limit directive, nginx limit_req) if needed for a multi-tenant
or public-facing deployment. On 429 from your proxy, back off for Retry-After seconds.
Don't loop tightly.
Errors¶
RFC 7807 application/problem+json. Switch on code, not detail:
{
"type": "https://saebooks.io/problems/resource_archived",
"title": "Resource Has Been Archived",
"status": 410,
"code": "resource_archived",
"detail": "Account 1100 is archived and cannot be used on new transactions."
}
code is stable across releases; title and detail prose are not. Validation errors (422) use code: "validation_failed" and include an errors array (list of pydantic field errors) rather than a fields map.
Testing¶
Run a local SAE Books instance per the quickstart. Every API endpoint that exists in production exists locally. There's no separate sandbox — you bring up your own.
For automated tests on your side, the dev/test bearer token
(SAEBOOKS_DEV_API_TOKEN) skips the JWT signing step and lets you
hit the API directly. Never set this in production.
Next¶
- Generated API reference — every endpoint
- LLM agent integration guide — same content, framed for AI-driven integrations