Skip to content

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_license on the shared bosun-postgres instance (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
}
  • edition is the canonical knob (community/offline/business/pro/ enterprise). seat_*_cap and company_cap are optional overrides on the per-edition default in services.licence.caps.
  • exp carries the end of the current Stripe billing period.
  • grace_until extends exp by SAE Engineering's grace policy (currently exp + 7d); customer can keep operating up to this point. The saebooks-api JWT driver enforces a separate grace window: days 0-30 past exp = writes permitted; days 31-60 = read-only (write routes return 402); past day 60 = drop to community, per CHARTER §6.6. The grace_until claim 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:

{ "current_token": "<JWT>" }

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:

{
  "enabled": true,
  "issued": 42,
  "limit": 1000,
  "remaining": 958
}

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 — mark canceled in DB. Next /refresh returns the downgrade token.
  • invoice.payment_failed — mark past_due in 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

license.saebooks.com.au {
    reverse_proxy r420:18309
}

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.