CoreMVP

Polar Billing

Configure lifetime and recurring Polar checkout, customer management and application access.

Set PAYMENT_PROVIDER=polar to use Polar for new lifetime and monthly/yearly subscription checkout. Stripe remains the default. Existing purchases and subscriptions keep their provider ownership and management paths when the checkout provider changes. Recurring checkout requires a signed-in account.

To remove an unused integration, follow Use One Billing Provider. Changing the checkout provider alone preserves installed readers, portals and webhook handling for existing billing.

Prepare the local application

Complete local setup, then apply pending feature migrations from the template root:

bunx supabase migration up --local

The Polar migrations add checkout/purchase, subscription and delivery-claim tables. They preserve existing Stripe records. Do not reset a database that contains data you need to keep.

Connect the CLI to the sandbox

Install the official Polar CLI, then check its commands:

polar --version
polar --help

Create a sandbox organization access token for the local workflow. Catalog creation and inspection need organizations:read and products:write; checkout and fulfillment need checkouts:write and orders:read; the CLI listener needs webhooks:write. Add discounts:write or refunds:write when testing those operations. Subscription lifecycle operations need subscriptions:write; customer portal creation needs customer_sessions:write. A single development token can serve both the CLI and local application.

Enter it privately in your terminal, without putting its value in shell history or command arguments:

printf 'Polar sandbox token: '
stty -echo
IFS= read -r POLAR_ACCESS_TOKEN
stty echo
printf '\n'
export POLAR_ACCESS_TOKEN
export POLAR_ENVIRONMENT=sandbox
polar organizations get <sandbox-organization-id> --fields id,name,slug --json

Stop if the returned organization is not your intended sandbox organization. Token environment variables override the CLI's saved OAuth session. Always set the environment explicitly: token authentication defaults to production. Sandbox accounts, tokens and product IDs are separate from production.

Create the recurring offers

The supplied payloads define Starter, Pro and Business, each as a separate monthly and yearly fixed-price product. Create the six offers through the native CLI:

for offer in starter-monthly starter-yearly pro-monthly pro-yearly business-monthly business-yearly; do
  polar products create --org <sandbox-organization-id> --data "$(cat "supabase/polar/subscriptions/$offer.json")" --fields id,name,prices --json
done
polar products list --org <sandbox-organization-id> --is-recurring=true --fields id,name,recurring_interval,prices --json

Keep the payload amounts and displayed plans in src/config/pricing.config.ts aligned. The server accepts the source tier and cadence, then verifies its configured product, organization, recurring interval and single fixed price. Missing mappings leave that offer unavailable; they never select Stripe as a fallback.

Create the lifetime offers

Create one fixed, one-time price per product. These commands match the example Individual and Team prices; replace their names and amounts when adapting your product:

polar products create --org <sandbox-organization-id> --data '{"name":"Individual","recurring_interval":null,"prices":[{"amount_type":"fixed","price_currency":"usd","price_amount":29900}]}' --json
polar products create --org <sandbox-organization-id> --data '{"name":"Team","recurring_interval":null,"prices":[{"amount_type":"fixed","price_currency":"usd","price_amount":79900}]}' --json
polar products list --org <sandbox-organization-id> --is-recurring=false --json

The integration supports a single active fixed catalog price on each configured product. Recurring, custom, free and ad-hoc price types are not lifetime offers. Keep displayed prices/features in src/config/pricing.config.ts aligned with your provider catalog.

Configure the application and listener

Save the development token in ignored .env.local; keep it server-only. The application itself uses checkouts:write, products:read, orders:read, subscriptions:read and customer_sessions:write. The CLI's catalog and test operations need the additional development permissions above. Add the organization and product IDs:

PAYMENT_PROVIDER=polar
POLAR_ENVIRONMENT=sandbox
POLAR_ORGANIZATION_ID=<sandbox-organization-id>
POLAR_INDIVIDUAL_PRODUCT_ID=<individual-product-id>
POLAR_TEAM_PRODUCT_ID=<team-product-id>
POLAR_STARTER_PRODUCT_ID_MONTHLY=<starter-monthly-product-id>
POLAR_STARTER_PRODUCT_ID_YEARLY=<starter-yearly-product-id>
POLAR_PRO_PRODUCT_ID_MONTHLY=<pro-monthly-product-id>
POLAR_PRO_PRODUCT_ID_YEARLY=<pro-yearly-product-id>
POLAR_BUSINESS_PRODUCT_ID_MONTHLY=<business-monthly-product-id>
POLAR_BUSINESS_PRODUCT_ID_YEARLY=<business-yearly-product-id>
POLAR_ACCESS_TOKEN=<sandbox-organization-access-token>

In your own terminal, start the listener:

polar webhook listen http://localhost:3000/api/webhooks/polar --org <sandbox-organization-id>

The listener displays a signing secret. Copy it into POLAR_WEBHOOK_SECRET in ignored .env.local, then start or restart bun run dev. Keep the secret out of command arguments, screenshots, logs and commits. Use your app's actual local port if it differs from 3000.

Open /pricing. The browser sends a source tier ID; the server selects the configured provider product. The application creates Checkout through the SDK. The current CLI exposes checkout inspection, not checkout creation. Open /pricing/subscription while signed in to choose a recurring offer. Customer Sessions for the Polar portal also use the SDK because CLI 3.0.0 does not expose that resource.

Prove a real sandbox purchase

You can first run the account-free regression tests:

bun run test

These exercise application decisions and signed fixtures with controlled provider responses. They do not prove a real payment or database round trip.

For guest onboarding proof, configure EMAIL_DELIVERY_MODE=test_capture, a server-only TEST_EMAIL_CAPTURE_TOKEN, and POLAR_TEST_EMAIL with a mailbox belonging to your sandbox organization. The test uses a unique plus-address alias. Restart the app after changing settings.

The lifetime suite requires both POLAR_INDIVIDUAL_PRODUCT_ID and POLAR_TEAM_PRODUCT_ID: the returning guest buys the second lifetime offer after setting up the account. The subscription suite requires both POLAR_PRO_PRODUCT_ID_MONTHLY and POLAR_PRO_PRODUCT_ID_YEARLY. These are prerequisites for the maintained test journeys; your application only needs mappings for the offers you sell.

Keep the app and listener running, then run:

./coremvp e2e billing:lifetime
./coremvp e2e billing:subscription

With Polar selected, this suite covers signed-in payment and guest payment followed by password setup. It expects a real sandbox checkout, signed paid order processing, persisted authenticated lifetime access and protected HTML and Markdown, and checks the Polar billing summary. Signing out must deny protected content. It uses Polar's sandbox card 4242 4242 4242 4242. The subscription suite runs Pro monthly and Pro yearly purchases with fresh signed-in buyers. It checks paid order fulfillment, both authenticated subscription readers, the owned Polar portal and protected HTML/Markdown. A recurring payment must not create lifetime access.

To exercise a discount, create a percentage discount restricted to your test products with polar discounts create --help, then set its code in POLAR_TEST_DISCOUNT_CODE before running the same suite. A 100% discount on an approved fixed offer completes without card inputs. Set POLAR_TEST_BILLING_COUNTRY="United Kingdom" for a separate tax-inclusive checkout. The default address is in the United States. These settings belong to the local test runner; they do not change the application's offer.

For SQL concurrency, immutable ownership, RLS and retained-migration proof against your disposable local Supabase instance, run:

bun --env-file=.env.local test ./e2e/integration/polar-repository.test.ts
bun --env-file=.env.local test ./e2e/integration/polar-subscription-repository.test.ts

This uses real local PostgreSQL with owned test rows and cleanup. It does not make a provider payment or replace the hosted-checkout suite.

Generated events are a separate ingress check:

polar webhook trigger order.paid --seed 7 --dry-run --json
polar webhook trigger order.paid --seed 7 --json

Generated IDs create no provider order or local checkout binding, so a generated paid event must not grant access. A signed fixture does not prove payment. Use polar checkouts get <checkout-id> and polar orders get <order-id> for provider readback after a real checkout.

Purchase and refund behavior

/api/webhooks/polar verifies the raw delivery signature. Only order.paid can establish the initial purchase, after Order and Checkout readback agree with the application's saved offer and buyer. A redirect, metadata or order.created does not grant access. Guests use the existing Supabase account setup and onboarding email path.

Lifetime access has the same refund policy as the existing Stripe integration: partial and full refunds do not automatically remove application access. Refund notifications do not create a purchase. A refund from Polar's dashboard may revoke Polar-hosted benefits separately; those are not this application's persisted access authority. Review this policy against your own product terms.

Changing PAYMENT_PROVIDER affects new checkout. Keep the previous provider's webhook endpoint and credentials configured for outstanding payments and retries. Persisted Stripe lifetime purchases and Polar purchases in the current POLAR_ENVIRONMENT continue to grant access.

Subscription access and customer management

A Polar subscription grants access while its current status is active and its verified funded interval contains the current time. A verified current trial can grant access until its trial end. Scheduled or actual termination caps either path; past-due, unpaid and terminal states deny access.

Each settled recurring order must identify the owned subscription/customer, approved fixed offer, currency and line service interval. An advanced provider billing period or a cycle-created notification alone does not extend access. Paid zero-value trial orders have no fixed-price line and do not establish funded coverage. An approved discounted fixed offer remains a separate case.

Plan and cadence changes among configured recurring offers use the actual settled replacement line, including a credit-funded annual-to-monthly change that shortens the funded interval. Older deliveries cannot restore superseded coverage. Bounded recovery can apply verified contiguous paid periods; a future, gapped or ambiguous interval preserves existing valid coverage and marks the subscription for reconciliation. There is no automatic unpaid grace period.

GET /api/user/status includes stored Stripe subscriptions and Polar subscriptions from the current POLAR_ENVIRONMENT, including ineligible states. Its managementTargets use the same Polar environment boundary; its singular subscription remains the first eligible relationship for compatibility. /api/user/subscription uses the same eligibility decision. The billing page lists those subscriptions and provider-labelled management actions even after paid access ends. Switching POLAR_ENVIRONMENT excludes the other environment's access and management targets while retaining its history. PAYMENT_PROVIDER selects new checkout independently of these readers. Stripe subscription actions resolve the owned subscription's actual Stripe customer, so a later customer snapshot cannot send the buyer to another billing account.

The browser posts an owned target from managementTargets to /api/billing/portal. The server authenticates the buyer and selects the provider customer. It returns only the destination URL, with a fixed application return path. Raw customer IDs, redirect parameters, email and metadata do not authorize portal access or establish purchase ownership.

Before taking live payments

Sandbox proof does not establish seller approval or live readiness. Review your provider account, product, privacy and buyer disclosures, refund policy, receipt/support responsibilities and hosted webhook configuration. A hosted Polar endpoint must subscribe to order.paid, order.refunded, subscription.created, subscription.updated, subscription.active, subscription.cycled, subscription.canceled, subscription.uncanceled, subscription.revoked, subscription.past_due, subscription.paused and subscription.resumed. Live setup uses separate production credentials and product IDs, with POLAR_ENVIRONMENT=production. Do not use sandbox cards there.

Limit the production application's token to checkouts:write, products:read, orders:read, subscriptions:read and customer_sessions:write. Catalog administration, subscription updates, discounts, refunds and webhook management are operator tasks; their development permissions do not belong in the deployed application's token.

The current prod e2e billing:lifetime and prod e2e billing:subscription lanes remain Stripe-specific. Run the documented local Polar sandbox journey and verify any later hosted Polar deployment with hosted Polar setup and the matching manual hosted billing checks. Real email delivery, refund processing and provider recovery require their own observed checks.

See Polar sandbox limitations, webhook delivery and refunds. For Stripe configuration, continue with Payments.

Was this page helpful?

On this page