Skip to main content

Envelopes & Errors

Success envelope

{
"data": {"id": "CHG-001", "status": "accepted", "...": "..."},
"meta": {
"schemaVersion": "1",
"requestId": "7f3c1f2e-0b2a-4d6b-8c9e-0a1b2c3d4e5f",
"serverTime": "2026-08-01T12:00:00Z",
"stateEpoch": "e7e8e9ea-...-...",
"storeRevision": 7
}
}

Stream variant GET /sync-snapshot:

{"data": {"bookings":[...],"charges":[...]},
"meta":{"schemaVersion":"1","requestId":"...","serverTime":"...","stateEpoch":"...","storeRevision":7,"streamEpoch":"...","streamCursor":"01H..."}}

Error envelope

{
"error": {"code":"charge.invalid_transition","message":"cannot pay an outstanding charge","details":{"currentStatus":"outstanding","event":"pay"}},
"meta": {"schemaVersion":"1","requestId":"...","serverTime":"...","stateEpoch":"...","storeRevision":7}
}

Exceptions (not enveloped): GET /attachments/{id} (bytes), GET /events (text/event-stream), POST /_dev/reset (204 empty).

Status mapping

HTTPerror.codeWhen
400request.idempotency_key_missingcommand without Idempotency-Key
400request.malformedinvalid JSON / multipart parse fail / wrong part content-type
404charge.not_foundunknown charge_id
404booking.not_foundhub/tasks for unknown booking
404inspection.not_found, report.not_found, notification.not_found, attachment.not_foundid not in snapshot
409charge.invalid_transitiontotal function says no
409charge.version_conflictexpectedVersion != current.version
409store.epoch_mismatchexpectedStateEpoch != snapshot.stateEpoch (client must refetch)
409idempotency.payload_mismatchsame key, different RFC 8785 digest
413attachment.total_too_largeMaxBodySizeMiddleware ceiling
409attachment.too_many>5 attachments
413attachment.file_too_largeper-file >10 MiB
422validation_failedPydantic strict errors → {field, rule} only
429chaos.injectedChaosController probabilistic 429

Unknown charge status filter → 422 with {status:[...], allowed:[...]}.

Idempotency semantics

  • Key scope: (method, normalized route template, resource id, Idempotency-Key).
  • Digest: SHA-256(RFC8785(canonicalJSON(body))); contest covers metadata + [[sha256,sizeBytes,mediaType,sanitizedDisplayName], ...].
  • Ledger: only committed 2xx stored inside same os.replace; same key+digest replays exact response (headers + body) even after restart; same key+different digest ⇒ 409; 4xx/5xx never cached; missing key on command ⇒ 400.

Details redaction

app/api/v1/endpoints/commands.py::_redact_errors keeps only {field, rule} from ValidationError.errors() — user prose like contest reason never leaks in an error body.

Examples (goldens)

Each under docs/contracts/examples/ is the canonical oracle for codegen tests.

cat docs/contracts/examples/error_charge_invalid_transition_409.json
cat docs/contracts/examples/error_store_epoch_mismatch_409.json
cat docs/contracts/examples/error_idempotency_payload_mismatch_409.json

Contract tests assert exact code + shape, not just status.

Client guidance

  1. Always send expectedStateEpoch from the last GET /hub or GET /charges/{id} meta.stateEpoch you observed.
  2. On 409 store.epoch_mismatch, drop optimistic state and fetch a fresh snapshot — don't replay.
  3. On 409 idempotency.payload_mismatch, you've reused a key for a different intent; generate a new UUID.
  4. On 413 attachment.*, shrink client-side before upload — server rejects before the multipart parser allocates.