saebooks-license-server — HTTP contract¶
Authoritative. Consumed by saebooks-api (
LicenseService.reload(),POST /api/v1/license/refresh,POST /api/v1/license/upload,GET /api/v1/license/snapshot), by saebooks-web (Settings → Licence "refresh now" button), by the marketing-site Stripe Checkout flow, and by Stripe webhooks. Lock this contract before implementing producers or consumers.
Deployment shape¶
- Hostname:
license.saebooks.com.au - Runtime: FastAPI + uvicorn behind Caddy, on r420 today.
- Storage: dedicated Postgres database
saebooks_licenseon the sharedbosun-postgresinstance (own schema + RLS). - Secrets:
SAEBOOKS_LICENSE_SIGNING_PRIVKEY_B64(Ed25519 priv key, raw 32-byte base64),STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET,LICENSE_SMTP_*. All env-driven; nothing committed. - Auth model:
/api/v1/license/refresh— unauthenticated; identity is proven by presenting an existing valid token signed by us./api/v1/license/issue-trial— unauthenticated; rate-limited per IP and per email; sends token to the verified email./stripe/webhook— Stripe signature verified./admin/*(later) — admin-group session JWT.
Token shape (signed by license-server, verified by saebooks-api)¶
JWT with header.alg = "EdDSA". Payload:
{
"license_id": "lic_<ulid>",
"ledger_id": "led_<ulid>",
"customer_id": "cus_<stripe>",
"edition": "business",
"licensed_to": "Acme Pty Ltd",
"iat": 1714600000,
"exp": 1717278400,
"grace_until": 1717883200,
"jti": "jti_<uuid>",
"seat_admin_cap": 2,
"seat_employee_cap": 3,
"company_cap": 2
}
editionis the canonical knob (community/offline/business/pro/ enterprise).seat_*_capandcompany_capare optional overrides on the per-edition default inservices.licence.caps.expcarries the end of the current Stripe billing period.grace_untilextendsexpby SAE Engineering's grace policy (currentlyexp + 7d); customer can keep operating up to this point. The saebooks-api JWT driver enforces a separate grace window: days 0-30 pastexp= writes permitted; days 31-60 = read-only (write routes return 402); past day 60 = drop to community, per CHARTER §6.6. Thegrace_untilclaim in the token is advisory and is used by the web UI for banner display; the enforcement logic runs in the resolver.
Routes¶
POST /api/v1/license/refresh¶
Refresh an existing token against current Stripe state.
Request:
Behaviour:
1. Verify signature on current_token with our public key. Reject
if invalid (HTTP 400 {"error":"invalid_token"}).
2. Look up license_id in the local DB.
3. Read Stripe subscription status:
- active / trialing → mint new token at the current edition;
exp = current_period_end, grace_until = exp + 7d.
- past_due → mint same edition, exp = now + 24h (short-cycle
for retry), grace_until = exp + 14d.
- canceled / unpaid → mint downgraded token: edition="community",
exp = now + 30d, grace_until = exp + 30d (give them time to
export).
4. Persist refresh event (license_id, ip, ua, jti_old, jti_new, ts).
5. Return:
{
"token": "<JWT>",
"edition": "business",
"expires_at": "2026-06-02T00:00:00Z",
"grace_until": "2026-06-09T00:00:00Z"
}
Error responses:
- 400 {"error":"invalid_token"} — signature/decode failure.
- 404 {"error":"unknown_license"} — token verified but not in DB
(tampered / revoked).
- 429 {"error":"too_many_refreshes"} — > 100 refreshes/24h for the
same license_id (abuse signal).
POST /api/v1/license/issue-trial¶
Mint a 14-day Business-edition trial without payment.
Request:
{
"email": "[email protected]",
"licensed_to": "Acme Pty Ltd",
"ledger_id": "led_<ulid>"
}
Behaviour:
1. Rate-limit: 1 trial per IP per 24h, 1 trial per email lifetime.
2. Persist trial row in DB.
3. Mint token with edition="business", 14-day exp.
4. Email token to email via SMTP.
5. Return {"status":"sent"} regardless of whether email already
exhausted its quota (avoid email-enumeration leak).
POST /api/v1/license/issue-launch-promo¶
Claim a launch-promo slot and receive a 12-month Pro-edition token.
Called by saebooks-api from the signup flow (services/launch_promo.py)
when LAUNCH_PROMO_ENABLED=true.
Request:
{
"email": "[email protected]",
"licensed_to": "Acme Pty Ltd"
}
Behaviour:
1. Check global counter; if exhausted, return 410 (counter done).
2. Idempotent on email — re-submission returns the same token.
3. Mint token with edition="pro", 12-month exp.
4. Return:
{
"token": "<JWT>",
"edition": "pro",
"license_id": "lic_<ulid>",
"expires_at": "2027-06-24T00:00:00Z",
"promo": true,
"promo_slot": 42,
"idempotent": false
}
Status codes: - 200/201 — token minted or re-issued. - 410 — counter exhausted; promo is over. - 503 — promo not active server-side (flag off or server cold).
GET /api/v1/license/promo-stats¶
Public counter endpoint — no auth required. Polled by the signup banner (60-second cache on the saebooks-api side) to show "N spots left".
Response:
POST /stripe/webhook¶
Stripe → license-server event consumer.
Verify Stripe-Signature header against STRIPE_WEBHOOK_SECRET.
Handle:
checkout.session.completed— locate or create license row, mint initial token, email to customer.customer.subscription.updated— update local sub state. No token push (client refreshes on its own schedule).customer.subscription.deleted— markcanceledin DB. Next/refreshreturns the downgrade token.invoice.payment_failed— markpast_duein DB.
Always return 200 to Stripe even on internal error (so Stripe doesn't retry-storm); log + alert internally.
GET /healthz¶
Returns {"status":"ok","service":"license-server","version":"<git_sha>"}.
saebooks-api endpoints (internal)¶
These are the API-side routes that consume the license-server contract. They are not part of the license-server itself.
| Method | Path | Auth | Notes |
|---|---|---|---|
GET |
/api/v1/license/snapshot |
Bearer (any user) | Current edition + source + caps (read-only). |
POST |
/api/v1/license/upload |
Bearer (any user) | Accept a portal JWT, verify, persist to cache, reload resolver. |
POST |
/api/v1/license/refresh |
Bearer (any user) | Call upstream /api/v1/license/refresh, persist returned token, reload resolver. |
GET |
/api/v1/license/promo-stats |
None | Proxy to upstream promo-stats; cached 60s. |
Snapshot response shape:
{
"edition": "business",
"source": "jwt",
"is_paid": true,
"is_perpetual": false,
"expires_at": "2026-06-02T00:00:00Z",
"licensed_to": "Acme Pty Ltd",
"ledger_id": "led_<ulid>",
"licence_id": null
}
source values: "jwt" | "usb" | "community_fallback".
licence_id is non-null only for USB (perpetual) licences.
ledger_id is non-null only for portal JWT (subscription) licences.
expires_at is null for USB and community editions.
Resolver precedence: USB → portal JWT → community fallback. Developer
override via SAEBOOKS_EDITION env var bypasses all drivers.
Database schema (initial)¶
Tables under schema license:
license—(id pk, ledger_id, customer_id, email, licensed_to, edition, seat_admin_cap, seat_employee_cap, company_cap, created_at, current_jti)subscription—(id pk, license_id fk, stripe_subscription_id, status, current_period_end, last_event_id, updated_at)refresh_event—(id pk, license_id fk, ip, ua, jti_old, jti_new, ts)trial—(id pk, license_id fk, email, ip, issued_at)webhook_event—(id pk, stripe_event_id unique, type, payload jsonb, received_at, processed_at)
All tables get a tenant_id column even though license-server is
single-tenant — discipline §4.1 from the infrastructure plan applies
to commercial servers too.
Caddy route¶
Host port 18309 (free as of 2026-05-02 — 18305/18306 are taken by
headscale/paperless-gpt).
Terminates TLS at OPNsense Caddy (post-migration) or r420 Caddy
(today). /api/v1/* and /stripe/webhook are unauthenticated
(license-token / Stripe-signature verified). /admin/* (later) is
gated by an admin-group session JWT.
Versioning¶
- Path-prefixed:
/api/v1/. Breaking changes go to/api/v2/. - Token format additions: backwards-compatible JSON additions only. Renames or required-claim additions = bump major and embed both old + new pubkey in saebooks-api during transition.