Documentation
Payments (Polar)
Polar billing in Motoko Base — setup, checkout, portal, webhooks, and entitlements.
Motoko Base uses Polar for subscriptions in v1. Billing is wrapped in a provider-agnostic API — feature code never imports Polar directly.
Official docs: Polar documentation
Architecture
Polar (source of truth)
↓
polar.customers.getStateExternal(userId)
↓
BillingSummary (plan, status, period end)
↓
Dashboard UI + hasPlan() / canAccess()Polar is the source of truth. Motoko Base does not mirror subscriptions in Postgres. Plan state is fetched from Polar Customer State and cached briefly (60s). Webhooks invalidate the cache when subscriptions change.
Better Auth user id = Polar externalId. A Polar customer is created on sign-up when billing is configured.
Setup
- Create a Polar organization — start in sandbox
- Create a Pro subscription product → copy product ID
- Create an Organization Access Token
- Add a webhook →
{APP_URL}/api/billing/webhooks/polar - Set env vars in
.env:
| Variable | Purpose |
|---|---|
BILLING_PROVIDER | polar (default) |
POLAR_ACCESS_TOKEN | Organization access token |
POLAR_WEBHOOK_SECRET | Webhook signing secret |
POLAR_PRO_MONTHLY_PRODUCT_ID | Pro monthly product ID |
POLAR_PRO_YEARLY_PRODUCT_ID | Pro yearly product ID (optional) |
POLAR_SERVER | sandbox or production |
Without Polar env vars the app stays on Free — no crash, checkout disabled.
Checkout & portal
| Action | API | UI |
|---|---|---|
| Upgrade to Pro | checkout(userId, "pro") | Billing page → Upgrade |
| Manage subscription | openPortal(userId) | Billing page → Manage billing |
Checkout redirects to Polar Checkout. Portal opens Polar Customer Portal (cancel, update payment method, invoices).
Webhooks
Route: src/app/api/billing/webhooks/polar/
Polar sends subscription events → signature verified → billing cache invalidated → analytics event captured. No local subscription ledger.
Subscribe to subscription events + customer.state_changed in Polar dashboard. Unhandled event types are ignored safely.
Entitlements
import { hasPlan, canAccess } from "@/lib/billing";
await hasPlan(userId, "pro");
await canAccess(userId, "ai"); // feature → plan map in billingConfigFeature→plan mapping lives in src/lib/billing/config.ts. Demo features are not gated in v1 — call canAccess when you add paid gates.
Plan labels in the UI: src/lib/billing/config/plans.ts.
Sandbox testing
- Set
POLAR_SERVER=sandboxand sandbox token/product ID - Use Polar test cards in checkout — see Polar sandbox docs
- Confirm webhook delivery in Polar dashboard (use ngrok locally if needed)
- After checkout, open
/dashboard/settings/administration/billing?checkout=success— plan should update
Switch to POLAR_SERVER=production with production credentials for live billing.
Where to change this
| What | Where |
|---|---|
| Public billing API | src/lib/billing/index.ts |
| Plans & feature gates | src/lib/billing/config.ts |
| Polar provider | src/lib/billing/providers/polar/ |
| Webhook handler | src/lib/billing/providers/polar/webhooks.ts |
| Billing UI & actions | src/features/dashboard/actions/billing.ts, src/features/dashboard/components/settings/billing-* |
Do not import @polar-sh/* from features or UI — use @/lib/billing only.
Environment Variables — all Polar env vars.