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)
| Product | Price | Deliverable |
|---|---|---|
| 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.txtto 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 filereleases/*.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:
| Product | Pricing | Benefit | metadata |
|---|---|---|---|
Founder | $15 one-time | Core (frozen) | slug: founder |
Lifetime | $50 one-time | Lifetime (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:
| Field | Value |
|---|---|
| URL | https://api.example.com/api/v1/billing/webhook |
| Events | order.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
- Visit
/resources/pricingand verify both pricing cards render. - Click Get Founder while logged out — should redirect to
/login?next=.... - Log in, click Get Founder again — auto-resume effect fires the checkout (no second click needed) and you land on Polar Sandbox.
- Pay with the test card
4242 4242 4242 4242(any future expiry, any CVC). - Get redirected to
/checkout/success?checkout_id=…. - Open
/downloads— your purchase should appear with an Open download portal button. - Check the database: a new
purchasesrow with the rightexternalOrderId,productName,amountCents. ThewireBillingReadModelListenerslistener wrote it from thebilling.invoice.paidevent.
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 fileThat'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.txtto 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
| File | Role |
|---|---|
apps/server/src/modules/billing/domain/purchase.ts | Purchase entity (sibling of Subscription) |
apps/server/src/modules/billing/application/use-cases/record-purchase.ts | Idempotent persist on invoice.paid |
apps/server/src/modules/billing/infrastructure/persistence/prisma-purchase-repository.ts | Prisma adapter |
apps/server/src/modules/billing/infrastructure/event-listeners.ts | Subscribes to billing.invoice.paid and writes Purchase rows |
apps/server/src/modules/billing/infrastructure/providers/polar-payment-provider.ts | Polar 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.ts | useMyPurchases, useStartCheckout, useOpenMyPortal |
apps/client/components/billing/purchases-card.tsx | Reusable card on /dashboard + /downloads |
apps/client/app/_marketing/pricing-cards.tsx | Client-side slug → priceId resolution + checkout |
scripts/build-release-zip.sh | Two-mode zip builder |
apps/server/__tests__/billing/record-purchase.test.ts | Unit tests for persistence + idempotency |