Billing
Subscription billing for a deployment. Polar.sh is the provider today; the module is written
against a PaymentProvider port so Paddle/PayPal/PayMongo can be added as adapters.
How it fits together
Browser ──► POST /api/billing/checkout (authed, system.admin.billing.manage)
│
▼
BillingService ──► PaymentProvider (port)
├── PolarPaymentProvider ──► sandbox-api.polar.sh
└── StubPaymentProvider (dev only, no network)
▲
│ canonical BillingEvent
POST /api/webhooks/{provider} (public, signature-verified)
│
▼
billing_subscription ◄── the only writer of paid state
The frontend never decides paid state. /checkout hands back a URL and nothing else. The
subscription only changes when a signed webhook arrives. The post-checkout redirect is a UX
hint — /billing/success polls the backend rather than assuming payment succeeded, because the
webhook can (and does) land after the browser gets back.
One deployment holds exactly one subscription — its own — because each client gets an isolated
stack. billing_subscription is a single-row table, like company_profile.
Nothing is enforced. A lapsed subscription is shown, not blocked. For a payroll system, a webhook bug or a Polar outage must never lock a client out of their own payroll during a cutoff. Enforcement is a product decision to make deliberately, not a TODO.
Key files
| Concern | File |
|---|---|
| The port | backend/.../service/billing/provider/PaymentProvider.java |
| Canonical event | backend/.../service/billing/provider/BillingEvent.java |
| Polar adapter | backend/.../service/billing/provider/PolarPaymentProvider.java |
| Signature check | backend/.../service/billing/provider/PolarWebhookVerifier.java |
| Polar → canonical | backend/.../service/billing/provider/PolarEventMapper.java |
| Idempotency + apply | backend/.../service/impl/BillingServiceImpl.java |
| Schema | backend/src/main/resources/db/migration/V52__billing.sql |
| UI | frontend/src/pages/billing/ |
Local testing — stub provider (no network)
The default. BILLING_PROVIDER=stub in .env, then:
docker compose up -d --build
Log in as a System Administrator, open http://localhost:5173/billing, pick a plan. The stub
"checkout" returns straight to /billing/success. Drive the webhook by hand:
curl -X POST http://localhost:8081/api/webhooks/stub \
-H 'Content-Type: application/json' \
-d '{"type":"subscription.active","plan_code":"growth"}'
Then confirm the state flipped:
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.
Local testing — real Polar sandbox
-
Sign up at sandbox.polar.sh — a separate account from production.
-
Create an organization and a recurring product. Copy the product UUID.
-
Create an Organization Access Token in the sandbox dashboard. Grant it at least the
checkouts:writescope — without it checkout creation fails with 403insufficient_scope. Sandbox tokens do not work against production and vice versa. -
Expose your local backend. The Polar CLI can tunnel (
polar listen), or usengrok http 8081/cloudflared tunnel. Port 8081 is the host-mapped backend fromdocker-compose.yml; underdev.sh(mvn spring-boot:run) it is 8080 instead. -
In the sandbox dashboard, add a webhook endpoint pointing at
https://<your-tunnel>/api/webhooks/polar, format Raw, subscribing tosubscription.*andorder.*. Set a secret (or copy the generated one). -
Fill in
.envand restart the backend:BILLING_PROVIDER=polarPOLAR_SERVER=sandboxPOLAR_ACCESS_TOKEN=polar_oat_...POLAR_WEBHOOK_SECRET=<the endpoint's secret, verbatim>POLAR_PRODUCT_ID_GROWTH=<product uuid> -
/billing→ pick a plan → pay with test card4242 4242 4242 4242, any future expiry, any CVC. -
You should land on
/billing/success, see the delivery in the tunnel/dashboard, and watchGET /api/billing/subscriptionturnActive.
If the CLI provisions a fresh secret per session, POLAR_WEBHOOK_SECRET changes and the backend
needs a restart.
Self-serve orders via checkout links
The public /pricing page can take money from prospects who don't have an account yet. This
uses Polar Checkout Links — static, dashboard-created URLs — so no payment code runs in the
app for these orders and no API token is involved.
Setup (once per plan, in the Polar dashboard):
-
Products → your product → Checkout Links → create one.
-
Set its success URL to
https://<your-marketing-domain>/order-received— a public page that tells the buyer their instance will be ready within 24 hours. -
Put the link URLs in the env of whichever deployment serves the marketing site:
BILLING_CHECKOUT_LINK_STARTER=https://buy.polar.sh/polar_cl_...BILLING_CHECKOUT_LINK_GROWTH=https://buy.polar.sh/polar_cl_... -
In Polar → Settings → Notifications, make sure you get an email on every new order — that email is what triggers the onboarding runbook in ../deployment/vps-guide.md.
The frontend reads the links from the public endpoint GET /api/public/billing/checkout-links
(PublicBillingController). Plans with a blank link fall back to a mailto: lead-capture CTA,
so a deployment with no links configured degrades gracefully. Signed-in users never see the
links — their CTA goes to the in-app /billing page instead.
Known limitation: a subscription bought this way predates the client's stack, so after
provisioning, that stack's /billing page shows None — its webhook endpoint didn't exist at
purchase time and billing state only ever arrives via webhooks. Harmless today (billing is
display-only; nothing is enforced). If it starts to matter, the fix is a boot-time sync that
pulls current subscription state from the Polar API.
The webhook secret gotcha
Polar follows Standard Webhooks, but not in the way a stock Standard Webhooks library expects. The spec treats the secret as base64 and libraries decode it before signing. Polar's SDKs base64-encode the configured secret and pass that to such a library, which decodes it right back — so the real HMAC key is the secret's raw UTF-8 bytes. Polar's docs say the same from the other side: "base64 encode the secret you configured on Polar in your code before generating the signature to validate against."
PolarWebhookVerifier therefore signs with secret.getBytes(UTF_8) directly. Getting this
wrong produces a signature mismatch on every delivery and no other symptom.
PolarWebhookVerifierTest pins both the correct derivation and the naive one, so a "fix" toward
the intuitive reading fails a test instead of failing in production.
Adding another provider (Paddle, PayPal, PayMongo)
- Implement
PaymentProviderinservice/billing/provider/, returning a newname(). - Map that provider's events onto
BillingEventTypein its own mapper. Map anything you don't act on toIGNORED— never throw, or the provider retries forever. - Add its config block to
BillingProperties. - Set
BILLING_PROVIDER=<name>.
BillingServiceImpl, the entity, the schema and the entire frontend stay untouched. If they
don't, the abstraction has sprung a leak worth fixing.
Webhooks resolve their provider from the URL path, so /api/webhooks/polar and
/api/webhooks/paddle can both be live while migrating.
Why the stub is guarded
StubPaymentProvider verifies no signatures, and /api/webhooks/** is public. If the bean
existed in production, anyone could POST a forged activation to /api/webhooks/stub. It carries
@ConditionalOnProperty(billing.provider=stub), so outside dev the bean doesn't exist and the
path 404s. Don't relax that.
Production notes
Each client stack is its own deployment, so each needs its own webhook endpoint registered in
Polar, pointing at https://<slug>.motorphenterprise.com/api/webhooks/polar.
scripts/onboard-client.sh prints this reminder; it is a manual step.
Be aware of where this goes: Polar delivers every event to every endpoint on the organization, so once several client stacks are registered, each receives other clients' events (they're recorded and ignored — an event for another subscription won't match this deployment's state), and the Polar token sits in every stack. That's fine for the first handful of clients and is the thing that eventually argues for a central control-plane service that owns billing and provisioning. The port and canonical event model are what make that a move of a self-contained module rather than a rewrite.