Skip to content

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/refresh with 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. Set ttl_days at creation (1–3650) or omit for no expiry. Revoke via DELETE /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):

curl "$SAEBOOKS_API_BASE/invoices?page=1&page_size=100" \
  -H "Authorization: Bearer $JWT"

Response: {items: [...], total: N, limit: 100, offset: 0}. Advance by incrementing page.

Cursor-based (cashbook, change log, and a small number of streaming endpoints):

curl "$SAEBOOKS_API_BASE/cashbook?cursor=<iso-datetime>" \
  -H "Authorization: Bearer $JWT"

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