saebooks-lodge-server — HTTP contract¶
Authoritative. Consumed by
RemoteLodgementServicein saebooks-api (Build #8). Lock this contract before implementing producers or consumers.
Deployment shape¶
- Hostname:
lodge.saebooks.com.au - Runtime: FastAPI + uvicorn behind Caddy, on r420 today.
- Storage: dedicated Postgres database
saebooks_lodgeon the sharedbosun-postgresinstance. - Secrets:
SAEBOOKS_PORTAL_PUBKEY(Ed25519 pubkey, raw 32-byte base64) — same key as embedded in saebooks-api; lodge-server uses it to verify customer-supplied licence tokens.SBR_MACHINE_CRED_PFX+SBR_MACHINE_CRED_PASSWORD— the ATO Machine Credential PFX, encrypted at rest.SBR_SSID— software subscriber identifier (SAE-Books).SBR_DSP_ABN— SAE Engineering's ABN for envelope signing.- Network: outbound to ATO SBR endpoints (
https://sbr.gov.au,https://ebms.softwareauthorisations.ato.gov.au). Inbound HTTPS only.
Auth model¶
Every lodge-server request includes a customer licence token
(JWT signed by license-server) in the Authorization: Bearer <token>
header. Server:
- Verifies signature with
SAEBOOKS_PORTAL_PUBKEY. - Checks
exp(with grace). - Checks
editionis in {pro,enterprise} for STP/BAS routes (Pro+ feature perservices.features._PRO_FLAGS). - Records
(license_id, jti, route, ts, payload_hash, ato_receipt_id)in audit log.
No additional auth — the licence token IS the auth.
Routes¶
POST /api/v1/stp/lodge¶
Request:
{
"envelope_xml": "<base64-encoded SBR3 STP envelope>",
"envelope_hash": "<sha256 of decoded envelope>",
"submitter_abn": "12345678901",
"payevent_id": "client-side UUID for idempotency",
"metadata": {
"pay_period_end": "2026-04-30",
"employee_count": 7,
"gross_total_cents": 423000
}
}
Notes:
- submitter_abn — active-company ABN, sourced client-side from CompanySettings.
- The body field name is per-envelope; idempotency is enforced on (license_id, payevent_id).
Behaviour:
1. Verify licence token, edition >= pro, has ato_sbr flag.
2. Verify envelope_hash == sha256(b64decode(envelope_xml)).
3. Sign envelope with SAE Engineering's Machine Credential.
4. POST ebMS3 to ATO SBR endpoint with SSID + ABN.
5. Persist audit row: license_id, jti, payevent_id, payload_hash,
ato_receipt_id, ato_status, raw_response.
6. Return:
{
"status": "accepted",
"ato_receipt_id": "<from ATO>",
"ato_timestamp": "2026-05-02T12:00:00Z",
"warnings": [{"code": "W001", "message": "Late lodgement"}]
}
warnings is a list of {code: str, message: str} objects; empty list when none.
Idempotency: if the same payevent_id + license_id is submitted again
within 24h, return the cached prior receipt instead of double-lodging.
Status / poll route — TODO (gated on PVT)¶
A QUEUED (202) lodgement returns a deferred receipt: the ATO has not yet
issued a final receipt. Resolving it later needs a status-retrieval route
(e.g. GET /api/v1/stp/status/{payevent_id}), backed by the ATO ebMS3
response-retrieval (SBR get-status / Pull) flow.
This route is NOT yet contracted. Its request/response shape and the
underlying ATO transport are deliberately left unspecified here because
they are gated on the ATO PVT (Product Verification Testing) reference
pack, which SAE Engineering does not yet hold. RemoteLodgementService.
poll_status raises NotImplementedError until this is locked; the
engine-side reconcile orchestration (services/stp.reconcile_stp_submission
/ reconcile_pending_stp) is already built and tested against a test
double, so only this transport seam is outstanding.
Status codes:
- 200 — accepted by ATO.
- 202 — queued (ATO returned a deferred receipt).
- 400 — envelope hash mismatch / malformed envelope.
- 401 — missing/invalid licence token.
- 403 — edition does not include ato_sbr.
- 422 — ATO rejected (validation error). Body:
{
"detail": "ATO rejected the lodgement",
"ato_errors": [
{"code": "CMN.ATO.GEN.XML05", "message": "Invalid ABN", "field": "Payer/ABN"}
]
}
ato_errors is [{code: str, message: str, field?: str}]; field is omitted
when the ATO error is not tied to a specific element.
- 502 — ATO SBR endpoint unreachable / 5xx. Client should retry with
backoff.
POST /api/v1/bas/lodge¶
Same shape as /stp/lodge but for BAS envelopes. The idempotency field is
period_id (not payevent_id); idempotency enforced on (license_id, period_id).
Request body differences:
{
"period_id": "client-side UUID for idempotency",
"metadata": {
"bas_period": "2026-Q1",
"gst_payable_cents": 12000
}
}
POST /api/v1/tpar/lodge¶
Same shape for TPAR. The idempotency field is year_id; enforced on
(license_id, year_id).
Request body differences:
{
"year_id": "client-side UUID for idempotency",
"metadata": {
"financial_year": "2025-26",
"contractor_count": 4
}
}
POST /api/v1/superstream/send¶
Same shape for SuperStream contribution messages. The idempotency field is
message_id; enforced on (license_id, message_id). Routed through
SAE Engineering's MessagingProvider relationship (TBD when SuperStream
work begins).
Request body differences:
{
"message_id": "client-side UUID for idempotency",
"metadata": {
"contribution_period_end": "2026-03-31",
"member_count": 3
}
}
POST /api/v1/abr/lookup¶
Looks up ABR data using SAE Engineering's API quota.
Request: {"abn": "12345678901"}
Response (mirrors AbrLookup cache schema in saebooks/services/abr/enrich.py):
{
"abn": "87 744 586 592",
"entity_name": "Sauer Pty Ltd ATF Saueesti Trust",
"entity_type": "Discretionary Investment Trust",
"gst_status": "Registered",
"gst_effective_from": "2024-02-15",
"abn_status": "Active",
"abn_status_effective_from": "2024-02-15",
"address_state": "QLD",
"address_postcode": "4350"
}
gst_status is "Registered" when gst_effective_from is non-null, else "Not registered".
entity_type maps to EntityTypeName (human-readable) from the raw ABR envelope.
abn_status_effective_from maps to AbnStatusEffectiveFrom from the raw ABR envelope.
GET /api/v1/audit/me¶
Returns the most recent 100 audit rows for the authenticated licence. Customer can use this to verify what's been lodged.
Row schema:
{
"id": 1234,
"route": "/api/v1/stp/lodge",
"payevent_id": "client-supplied UUID (field name matches the route's id field)",
"payload_hash": "sha256hex",
"ato_receipt_id": "<from ATO, or null if stub/pending>",
"ato_status": "accepted",
"ts": "2026-05-03T02:00:00Z"
}
payevent_id here is the generic label for the per-route ID field
(payevent_id / period_id / year_id / message_id). raw_response_jsonb
is stored server-side only and is NOT returned to the client.
GET /healthz¶
Standard health probe.
Stub mode¶
Build #7 ships all routes returning 501 Not Implemented with a
deterministic body:
{
"status": "stub",
"would_have_lodged": true,
"stub_receipt_id": "stub_<uuid>",
"comment": "lodge-server is stubbed. SBR Machine Credential onboarding pending — see ato-sbr-onboarding memory."
}
But the licence-token verification, edition check, and audit row
persistence are LIVE. This means RemoteLodgementService can be
written and tested end-to-end now, and the ebMS3 layer slots in
behind the existing route when the SBR onboarding completes.
Database schema (initial)¶
lodgement_audit—(id pk, license_id, jti, route, payevent_id, payload_hash, ato_receipt_id, ato_status, raw_response_jsonb, client_ip, ts)idempotency—(license_id, payevent_id, ato_receipt_id, ts)with(license_id, payevent_id)unique. Thepayevent_idcolumn holds whichever per-route ID field was supplied (payevent_id,period_id,year_id, ormessage_id).
Retention: ATO requires 5-year retention for STP records. Bank-feed audit rows retained per SISS contract terms.
Caddy route¶
Host port 18310 (free as of 2026-05-02).
Licence-token auth is the only auth.
Changelog¶
- 2026-05-03 — locked 6 ambiguities raised by Build #8 (payevent_id naming, submitter_abn source, warnings/ato_errors shapes, abr lookup keys, audit row schema).
- 2026-06-27 — added status/poll route TODO note;
RemoteLodgementService.poll_statusraisesNotImplementedErroruntil ATO PVT pack is obtained.