Billing & Webhooks API
Deliberately thin: the billing module's architecture, provider port (Polar.sh + stub), configuration, and testing walkthroughs live in ../backend/billing.md — this page is just the endpoint contract.
Billing admin — /api/billing
BillingController.java
— class-level @PreAuthorize, so both endpoints require
system.admin.billing.manage:
| Method | Path | Permission | Purpose |
|---|---|---|---|
| GET | /api/billing/subscription | system.admin.billing.manage | Current subscription state for this deployment |
| POST | /api/billing/checkout | system.admin.billing.manage | Create a provider checkout session; returns the checkout URL only |
The checkout response never flips any paid state — subscription state changes arrive exclusively through a verified webhook (below), so a user abandoning checkout leaves nothing half-updated.
Public checkout links — /api/public/billing
PublicBillingController.java — unauthenticated, consumed by the marketing site's pricing page:
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /api/public/billing/checkout-links | none | { "<planCode>": "<provider-hosted checkout URL>", ... } — plans without a configured link are omitted. Dashboard-created URLs only; no secrets, no state. |
Provider webhooks — /api/webhooks/{provider}
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /api/webhooks/{provider} | signature, not JWT | Provider event intake ({provider} = polar or stub) |
This endpoint is deliberately outside JWT auth (providers don't have tokens — it's in the security config's public list). Authenticity comes from the per-provider signature check over the raw request bytes: the body is bound as a raw string precisely so verification sees the exact bytes that were signed, and all inbound headers are handed to the provider adapter. Polar's Standard-Webhooks-style verification (and its secret-format gotcha) is documented in ../backend/billing.md. Invalid signatures are rejected; a verified event updates the local subscription state.
Local testing with the stub provider
With the default dev stack (stub provider active), drive a webhook by hand — the stub accepts unsigned events:
curl -X POST http://localhost:8081/api/webhooks/stub \
-H 'Content-Type: application/json' \
-d '{"type":"subscription.active","plan_code":"growth"}'
# confirm the state flipped (needs a system-admin token — see api/README.md quickstart)
curl -s http://localhost:8081/api/billing/subscription \
-H "Authorization: Bearer $TOKEN"
Other type values the stub understands: subscription.canceled,
subscription.revoked, subscription.past_due. Testing against the real
Polar sandbox (tunnels, secrets, scopes): ../backend/billing.md.