SaaS Starter
Deploy

Polar.sh

Sell digital products via Polar.sh through the canonical IPaymentProvider port. Same architecture used to sell UseDeploy itself, ready for any boilerplate buyer to use for their own products.

This page documents how the boilerplate sells digital products through Polar.sh. The same code path also powers the live usedeploy.dev checkout where the boilerplate itself is sold — there's only one billing architecture, the canonical IPaymentProvider port. A buyer of the boilerplate inherits a working setup and can ship their own products by changing env vars and Polar dashboard config.

If you only need a quick sketch of what IPaymentProvider is, see the provider-agnostic overview at Billing. This page is the operator's playbook specifically for Polar.

Architecture

Polar lives behind the same IPaymentProvider port as Stripe and MercadoPago. The flow:

[browser]
   │ POST /billing/checkout { priceId, mode, successUrl, cancelUrl }

[server]
   │ startCheckoutUseCase

[IPaymentProvider]
   │ PolarPaymentProvider.createCheckoutSession()

[Polar]
   │ checkouts.create → 302 → Polar-hosted checkout

[buyer pays]
   │ Polar emits order.paid webhook

[server]
   │ POST /billing/webhook (HMAC-verified by PolarPaymentProvider)
   │ → BillingEvent.invoice.paid published on the event bus
   │ → wireBillingReadModelListeners writes a Purchase row

[dashboard]
   GET /billing/my-purchases → "Your purchases" card
   POST /billing/portal/me  → window.open(Polar portal URL)

There's no second checkout path. The previous @polar-sh/better-auth plugin and usedeploy-orders/ bounded context were removed in the consolidation refactor — they duplicated what IPaymentProvider already modeled and made the boilerplate's billing harder for buyers to inherit.

Two products on first launch (UseDeploy.dev itself)

ProductPriceDeliverable
Founder$15 (one-time)usedeploy-core-vX.Y.zip — a frozen snapshot, no future updates
Lifetime$50 (one-time)usedeploy-latest.zip — Polar always serves the latest uploaded version; existing buyers see the new file on their next portal visit

[!IMPORTANT] Never overwrite the Core (frozen) zip. Founder buyers received the exact zip they bought and expect it to be immutable. The release script writes releases/CORE_FROZEN.txt to refuse accidental overwrites — see Release zips.

One-time setup

1. Build the two release zips

bun run release:core --rev v2.1.0   # Founder, frozen
bun run release:latest              # Lifetime, replaces previous file

releases/*.zip is gitignored. The lockfile releases/CORE_FROZEN.txt is checked in — it documents which Core build is currently sold.

2. Polar Sandbox account

Sign up at https://sandbox.polar.sh. Create an organization for your brand.

Benefits

In Benefits → New benefit, create two of type File Downloads and upload the matching zip to each.

Products

In Products → New product, create two products and attach one benefit each. Critical: add metadata.slug to each product so the client can resolve catalog entries by slug:

ProductPricingBenefitmetadata
Founder$15 one-timeCore (frozen)slug: founder
Lifetime$50 one-timeLifetime (latest)slug: lifetime

The slug metadata is what the boilerplate's pricing-cards.tsx component uses to find the priceId for each card — it's a contract between your dashboard config and your UI.

Webhook

In Settings → Webhooks → Add endpoint:

FieldValue
URLhttps://api.example.com/api/v1/billing/webhook
Eventsorder.paid, subscription.created, subscription.updated, subscription.canceled, subscription.revoked, subscription.active

Copy the webhook secret — Polar shows it once.

API token

In Settings → Developers → New token, generate one with all scopes. Copy the token.

3. Server env vars

Set these on the server service (Railway, Fly, Render, …):

PAYMENT_PROVIDER=polar
POLAR_ACCESS_TOKEN=polar_oat_…
POLAR_WEBHOOK_SECRET=polar_whs_…
POLAR_SERVER=sandbox            # or "production"

That's it. No POLAR_PRODUCT_*_ID — the boilerplate resolves products via the metadata.slug field on the catalog entries returned by /billing/plans.

4. End-to-end smoke test on Sandbox

  1. Visit /resources/pricing and verify both pricing cards render.
  2. Click Get Founder while logged out — should redirect to /login?next=....
  3. Log in, click Get Founder again — auto-resume effect fires the checkout (no second click needed) and you land on Polar Sandbox.
  4. Pay with the test card 4242 4242 4242 4242 (any future expiry, any CVC).
  5. Get redirected to /checkout/success?checkout_id=….
  6. Open /downloads — your purchase should appear with an Open download portal button.
  7. Check the database: a new purchases row with the right externalOrderId, productName, amountCents. The wireBillingReadModelListeners listener wrote it from the billing.invoice.paid event.

If any step fails, check the server logs for billing read-model purchase failed warnings — the listener wraps everything in try/catch so a write error never fails the webhook ack.

5. Production cutover

Repeat steps 2–4 on https://polar.sh (production, not sandbox). The products + benefits + webhook + token are entirely separate; do not reuse sandbox IDs. Set POLAR_SERVER=production in production env.

Smoke test with a real $15 Founder purchase, then refund the test purchase from Polar's dashboard.

Recurring task: every release

There's exactly one action recurring after a new boilerplate release:

# Build the latest zip
bun run release:latest

# Then in Polar dashboard:
#   Benefits → "UseDeploy Lifetime (latest)" → Replace file

That's it. All current Lifetime customers see the new file the next time they open their portal — Polar handles the cache invalidation.

[!WARNING] Never re-upload the Core (frozen) benefit. Founder buyers received the exact zip you sold them and expect it to be immutable. The build script writes releases/CORE_FROZEN.txt to enforce this — refuses to overwrite the Core zip without manual intervention.

Refunds

Polar handles refund flow on their side: customer requests via Polar email or you issue from the dashboard. The webhook fires order.refunded, which the current parseWebhook ignores (returns null). We log it but don't act on it — buyer keeps whatever zip they already downloaded (practically irrevocable for digital goods).

Future v2: revoke portal access via Polar's API on refund. Track in the follow-ups roadmap.

Files & code

FileRole
apps/server/src/modules/billing/domain/purchase.tsPurchase entity (sibling of Subscription)
apps/server/src/modules/billing/application/use-cases/record-purchase.tsIdempotent persist on invoice.paid
apps/server/src/modules/billing/infrastructure/persistence/prisma-purchase-repository.tsPrisma adapter
apps/server/src/modules/billing/infrastructure/event-listeners.tsSubscribes to billing.invoice.paid and writes Purchase rows
apps/server/src/modules/billing/infrastructure/providers/polar-payment-provider.tsPolar SDK calls behind the IPaymentProvider port
apps/server/src/modules/billing/interfaces/http/billing.routes.ts/billing/{checkout,portal,portal/me,webhook,my-purchases,plans}
apps/client/lib/hooks/use-purchases.tsuseMyPurchases, useStartCheckout, useOpenMyPortal
apps/client/components/billing/purchases-card.tsxReusable card on /dashboard + /downloads
apps/client/app/_marketing/pricing-cards.tsxClient-side slug → priceId resolution + checkout
scripts/build-release-zip.shTwo-mode zip builder
apps/server/__tests__/billing/record-purchase.test.tsUnit tests for persistence + idempotency

On this page