Skip to content

Conventions

URL shape

  • Base: https://app.saebooks.com.au/api/v1
  • Resource collection: /api/v1/<resources> (plural)
  • Resource item: /api/v1/<resources>/{id} — id is always a UUIDv4
  • Sub-resource: /api/v1/<resources>/{id}/<children>

Verbs

Verb Purpose
GET Read; pagination via ?page= / ?page_size= or ?limit= / ?offset=
POST Create; body is JSON, returns the created resource
PATCH Partial update; body is the diff
PUT Full replace (rarely used)
DELETE Soft-delete by default; ?hard=true for hard-delete (admin)

Pagination

Offset-based. Most list endpoints accept ?page= (1-based, default 1) and ?page_size= (default 50, max 500). Some endpoints use ?limit= / ?offset= directly. All list responses share the same envelope:

GET /api/v1/invoices?page=1&page_size=50
→
{
  "items": [...],
  "total": 312,
  "limit": 50,
  "offset": 0
}

Errors

All non-2xx JSON responses conform to RFC 7807 Problem Details (Content-Type: application/problem+json):

{
  "type":   "https://saebooks.io/problems/invoice_not_found",
  "title":  "Invoice Not Found",
  "status": 404,
  "detail": "No invoice with that ID exists in this tenant.",
  "code":   "invoice_not_found"
}

code is a stable machine-readable identifier — key on it rather than parsing detail prose. For Pydantic validation failures (422), the response adds an errors array with per-field detail:

{
  "type":   "https://saebooks.io/problems/validation_failed",
  "title":  "Validation Failed",
  "status": 422,
  "code":   "validation_failed",
  "detail": "Request body or query parameters failed validation.",
  "errors": [{"loc": ["body", "amount"], "msg": "must be >= 0", ...}]
}

HTTP status follows the usual conventions: 400 bad request, 401 missing/bad bearer, 403 role gate failed, 404 not found, 409 write conflict (version mismatch or idempotency key conflict), 422 schema validation failure, 428 precondition required (missing If-Match), 5xx server fault.

Idempotency

Most write endpoints accept X-Idempotency-Key: <uuid>. On the first request the response is stored; retries with the same key and identical body return the stored response verbatim. Submitting the same key with a different request body returns 409 with code idempotency_key_conflict.

Optimistic locking

PATCH and DELETE on versioned resources (invoices, bills, payments, journal entries, etc.) require If-Match: <version>, where <version> is the integer version field from the most-recent GET response. A mismatch returns 409 with code version_mismatch. Omitting the header on a route that requires it returns 428 with code precondition_required.

Time

All timestamps are ISO-8601 with timezone (...+10:00 for AEST). The server timezone is Australia/Brisbane.