Sabo ships with a full Polar integration using the official
@polar-sh/nextjs SDK. This guide covers everything from environment setup to webhook processing.Overview
Sabo’s Polar integration provides:- Hosted checkout via
/api/checkoutwith Polar’s Checkout UI - Customer portal powered by Polar’s self-serve billing pages
- Centralized plan management through
src/lib/payments/plans.ts - Webhook syncing that keeps
user_subscriptionsandpayment_historytables up to date - Automatic invoices: the webhook handler requests hosted invoice URLs for each order
Quick Start
1
Create Polar access tokens
Set up your Polar workspace.
- Visit Polar Dashboard and create/sign into your organization.
- Navigate to Settings → Developers → Access Tokens.
- Create a token and copy the value (it starts with
polar_at_...). - In Products, create “Pro Monthly” and “Pro Yearly” subscriptions so you can capture their product IDs.
2
Add environment variables
Update
.env.local with Polar settings:.env.local
3
Apply database migrations
Polar adds new columns for customer + order tracking.This applies
supabase/migrations/20240201000000_add_polar_columns.sql, which adds polar_customer_id, polar_subscription_id, and polar_order_id fields that the webhooks rely on.4
Expose webhooks
Use ngrok (or any tunneling tool) so Polar can reach your local Then configure your webhook in Polar Dashboard → Settings → Webhooks:
/api/webhooks/polar endpoint.- URL:
https://<your-ngrok-host>.ngrok-free.app/api/webhooks/polar - Events:
subscription.created,subscription.updated,subscription.active,subscription.canceled,subscription.revoked,order.created,order.paid,order.refunded - Signing secret: copy it into
POLAR_WEBHOOK_SECRET
5
Test checkout end-to-end
- Visit
http://localhost:3000/pricing. - Click Upgrade to Pro. If you’re logged out you’ll be redirected to
/sign-in. - After login, the pricing component builds
/api/checkout?products=PRODUCT_ID&metadata={"user_id":"..."}and redirects you to Polar Checkout. - Use the sandbox card
4242 4242 4242 4242(any future expiry/CVC). - On success, you’re sent to
/success, webhooks run, and/dashboard/settings/billingshows the updated subscription.
Watch your terminal for
subscription.* and order.* webhook logs. Each should return 200 OK.Configuration
Plans
src/lib/payments/plans.ts stores Polar product IDs for each plan:
src/lib/payments/plans.ts (excerpt)
Helper Functions
The same file exports helper utilities used by pricing UI, API routes, and webhooks:src/lib/payments/plans.ts (helpers)
Environment Reference
| Variable | Purpose |
|---|---|
POLAR_ACCESS_TOKEN | Required by /api/checkout, /api/portal, and invoice fetching |
POLAR_WEBHOOK_SECRET | Validates webhook signatures |
POLAR_SANDBOX | Controls whether webhook helper hits sandbox or production API |
NEXT_PUBLIC_POLAR_PRODUCT_ID_* | Populates the pricing UI and checkout query |
SUPABASE_SECRET_KEY | Allows webhook handler to bypass RLS when writing tables |
API Endpoints
| Endpoint | Method | Description |
|---|---|---|
/api/checkout | GET | Wraps Checkout() from @polar-sh/nextjs and redirects users to Polar Checkout |
/api/portal | GET | Opens the Polar customer portal after resolving polar_customer_id from Supabase |
/api/webhooks/polar | POST | Processes subscription + order lifecycle events |
Checkout Flow
The marketing pricing component builds the checkout URL client-side so metadata reaches Polar:src/components/marketing/pricing.tsx (excerpt)
Checkout handler:
src/app/api/checkout/route.ts
Customer Portal
/api/portal uses CustomerPortal from @polar-sh/nextjs and fetches polar_customer_id from the authenticated user’s user_subscriptions row. If no subscription exists, the route throws No subscription found so the UI can surface an error.
Webhook Handler
src/app/api/webhooks/polar/route.ts wires every subscription/order event into Supabase via the service client.
Supported events
| Event | Action |
|---|---|
subscription.created | Upserts user_subscriptions using metadata user_id |
subscription.updated | Keeps plan, status, billing cycle, cancellation fields in sync |
subscription.active | Forces status to active after successful payment |
subscription.canceled | Records cancel_at_period_end, cancel_at, canceled_at |
subscription.revoked | Immediately sets status to canceled |
order.created | Logged for visibility (no DB writes) |
order.paid | Inserts into payment_history and fetches invoice URL via Polar REST API |
order.refunded | Inserts a negative amount row with status refunded |
Key implementation details
Metadata fallback
Metadata fallback
Checkout stores
user_id inside metadata. The webhook also checks subscription.customer.metadata.user_id for backwards compatibility so existing customers still map to Supabase users.Invoice download links
Invoice download links
getInvoiceUrl(order.id) first triggers invoice generation (POST) then fetches the hosted invoice URL (GET). The resulting link is stored in payment_history.invoice_url so users can download receipts from the billing page.Service client
Service client
The handler calls
createServiceClient() which uses SUPABASE_SECRET_KEY. Without it, Supabase’s Row Level Security would block writes to user_subscriptions and payment_history.Database Schema
After running the migration, the following Polar-specific fields are available:user_subscriptions
| Column | Description |
|---|---|
polar_customer_id | Polar customer identifier (cus_...) |
polar_subscription_id | Polar subscription ID (sub_...) |
polar_product_id | Product ID tied to the plan |
billing_cycle | Derived from the product (month / year) |
cancel_at, cancel_at_period_end, canceled_at | Cancellation metadata synced from Polar |
payment_history
| Column | Description |
|---|---|
polar_subscription_id | Subscription associated with the charge |
polar_order_id | Order ID emitted by Polar |
invoice_url | Hosted invoice link returned by the helper |
amount, currency, status | Mirror Polar’s netAmount, currency, and payment state |
polar_products
The migration also creates a polar_products table for caching product metadata:
| Column | Type | Description |
|---|---|---|
id | TEXT (PK) | Polar product ID |
name | TEXT | Product name |
description | TEXT | Product description |
active | BOOLEAN | Whether the product is active (default true) |
created_at / updated_at | TIMESTAMPTZ | Record timestamps |
You can inspect the full schema inside
supabase/migrations/20240201000000_add_polar_columns.sql for the exact SQL.Testing
Sandbox cards
| Card number | Use case |
|---|---|
4242 4242 4242 4242 | Successful payment |
4000 0000 0000 0002 | Declined payment |
Checklist
Checkout + portal
Checkout + portal
- Complete a sandbox checkout via
/pricing. - Confirm
/successloads. - Visit
/dashboard/settings/billingand click Manage Subscription to ensure/api/portalresolves apolar_customer_id.
Webhook processing
Webhook processing
- Watch the terminal for
subscription.created,order.paid, etc. - Verify
user_subscriptionsnow haspolar_customer_id+polar_subscription_id. - Confirm
payment_historycontains the amount and invoice URL.
Cancellation flow
Cancellation flow
- Use the Polar customer portal to cancel.
- Expect
subscription.canceled→cancel_at_period_endupdates, and status changes propagate to the billing UI.
Production Deployment
1
Switch to production mode
Set
POLAR_SANDBOX=false and deploy with real access tokens, webhook secret, and product IDs. Update NEXT_PUBLIC_SITE_URL to your deployed domain so checkout success + portal return URLs are correct.2
Configure webhook endpoint
Add
https://yourdomain.com/api/webhooks/polar in the Polar dashboard (production org). Copy the new signing secret into your production environment.3
Smoke test
Before inviting customers, create a low-priced test plan, run a real payment, and ensure invoices populate. Cancel the subscription through the Polar portal to double-check cancellation events.
Advanced Topics
One-Time Payments & Digital Products
Polar supports one-time payments (lifetime access, digital products, credits) in addition to recurring subscriptions. Polar handles this entirely via the dashboard—no code changes required:- Create a one-time product in Polar Dashboard → Products → Add Product → Set pricing model to “One-time”
- Copy the product ID and add it to your
plans.tsconfiguration - The same
/api/checkoutendpoint works—Polar automatically detects the product type
Custom Trial Periods
Trial periods are configured directly in Polar when creating or editing a product:- Go to Polar Dashboard → Products → Select your subscription product
- Enable Free Trial and set the trial duration (e.g., 14 days)
- Polar automatically handles trial start/end dates and notifies users before conversion
trialStart and trialEnd fields in subscription payloads. You can extend the webhook handler to store these in user_subscriptions if needed.
Trial configuration lives in Polar, not in code. This keeps pricing logic centralized and easy to update without redeploying.
Promotional Codes & Discounts
Create and manage promotional codes through Polar Dashboard:- Navigate to Polar Dashboard → Discounts → Create Discount
- Set discount type (percentage or fixed amount), duration, and redemption limits
- Share the generated code with customers
Troubleshooting
Webhooks not firing
Webhooks not firing
- Ensure ngrok (or your reverse proxy) is running.
- Double-check
POLAR_WEBHOOK_SECRETmatches the dashboard value. - Confirm the webhook endpoint returns
200in Polar’s event log.
No subscription found
No subscription found
/api/portal throws when polar_customer_id is missing. Verify the webhook processed the latest subscription.* event and that user_id metadata was attached during checkout.Invoice URL is null
Invoice URL is null
Invoices can take a moment to generate. The helper already retries by triggering generation, but if
null persists, re-fetching later usually resolves it.Sandbox vs production confusion
Sandbox vs production confusion
If checkout keeps pointing at sandbox, set
POLAR_SANDBOX=false and redeploy. Also update /api/checkout to pass server: "production" when building the handler.Plan name shows as null or 'Unknown Plan'
Plan name shows as null or 'Unknown Plan'
The webhook uses
subscription.product?.name to populate plan_name. If this is missing, verify your Polar product has a name set in the dashboard, or check that getPlanByProductId() in plans.ts includes the product ID.