Skip to main content
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/checkout with 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_subscriptions and payment_history tables 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.
  1. Visit Polar Dashboard and create/sign into your organization.
  2. Navigate to Settings → Developers → Access Tokens.
  3. Create a token and copy the value (it starts with polar_at_...).
  4. In Products, create “Pro Monthly” and “Pro Yearly” subscriptions so you can capture their product IDs.
Access tokens are secret. Store them only in server-side environment variables.
2

Add environment variables

Update .env.local with Polar settings:
.env.local
# Site URL (used for checkout success + portal return)
NEXT_PUBLIC_SITE_URL=http://localhost:3000

# Polar credentials
POLAR_ACCESS_TOKEN=polar_at_your_access_token
POLAR_WEBHOOK_SECRET=polar_wh_your_webhook_secret
POLAR_SANDBOX=true # set false in production

# Polar product IDs (from dashboard)
NEXT_PUBLIC_POLAR_PRODUCT_ID_PRO_MONTHLY=prod_monthly_id
NEXT_PUBLIC_POLAR_PRODUCT_ID_PRO_YEARLY=prod_yearly_id

# Supabase service key for webhooks
SUPABASE_SECRET_KEY=sb_secret_your_service_key
POLAR_SANDBOX=true switches the webhook helper to https://sandbox-api.polar.sh. Leave it enabled for local development.
3

Apply database migrations

Polar adds new columns for customer + order tracking.
# From the sabo repository root
supabase db push
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 /api/webhooks/polar endpoint.
# Terminal 1
ngrok http 3000

# Terminal 2
pnpm dev
Then configure your webhook in Polar Dashboard → Settings → Webhooks:
  • 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

  1. Visit http://localhost:3000/pricing.
  2. Click Upgrade to Pro. If you’re logged out you’ll be redirected to /sign-in.
  3. After login, the pricing component builds /api/checkout?products=PRODUCT_ID&metadata={"user_id":"..."} and redirects you to Polar Checkout.
  4. Use the sandbox card 4242 4242 4242 4242 (any future expiry/CVC).
  5. On success, you’re sent to /success, webhooks run, and /dashboard/settings/billing shows 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)
export interface Plan {
  id: string;
  name: string;
  description: string;
  monthlyPrice: number | null;
  yearlyPrice: number | null;
  polarProductIds: {
    monthly: string | null;
    yearly: string | null;
  };
  features: string[];
  isPopular: boolean;
  buttonText: string;
  isContactUs?: boolean;
  isFree?: boolean;
}

export const plans: Plan[] = [
  {
    id: "pro",
    name: "Pro",
    description: "Ideal for professionals and small teams",
    monthlyPrice: 12,
    yearlyPrice: 10, // $120/year = $10/month
    polarProductIds: {
      monthly: process.env.NEXT_PUBLIC_POLAR_PRODUCT_ID_PRO_MONTHLY || null,
      yearly: process.env.NEXT_PUBLIC_POLAR_PRODUCT_ID_PRO_YEARLY || null,
    },
    features: ["Custom domain", "SEO-optimizations", /* ... */],
    isPopular: true,
    buttonText: "Upgrade to Pro",
  },
  // ...free and enterprise plans omitted...
];

Helper Functions

The same file exports helper utilities used by pricing UI, API routes, and webhooks:
src/lib/payments/plans.ts (helpers)
export function getPlanById(planId: string): Plan | undefined {
  return plans.find((plan) => plan.id === planId);
}

export function getPlanByProductId(productId: string): Plan | undefined {
  return plans.find(
    (plan) =>
      plan.polarProductIds.monthly === productId ||
      plan.polarProductIds.yearly === productId
  );
}

export function isYearlyProduct(productId: string): boolean {
  return plans.some((plan) => plan.polarProductIds.yearly === productId);
}

export function getBillingCycle(productId: string): "month" | "year" {
  return isYearlyProduct(productId) ? "year" : "month";
}

export function formatPrice(amount: number): string {
  return new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: "USD",
    minimumFractionDigits: 0,
  }).format(amount);
}
These helpers keep the pricing UI, API routes, and webhooks in sync.

Environment Reference

VariablePurpose
POLAR_ACCESS_TOKENRequired by /api/checkout, /api/portal, and invoice fetching
POLAR_WEBHOOK_SECRETValidates webhook signatures
POLAR_SANDBOXControls whether webhook helper hits sandbox or production API
NEXT_PUBLIC_POLAR_PRODUCT_ID_*Populates the pricing UI and checkout query
SUPABASE_SECRET_KEYAllows webhook handler to bypass RLS when writing tables

API Endpoints

EndpointMethodDescription
/api/checkoutGETWraps Checkout() from @polar-sh/nextjs and redirects users to Polar Checkout
/api/portalGETOpens the Polar customer portal after resolving polar_customer_id from Supabase
/api/webhooks/polarPOSTProcesses 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)
const checkoutUrl = new URL("/api/checkout", window.location.origin);
checkoutUrl.searchParams.set("products", productId);
checkoutUrl.searchParams.set("customerEmail", user.email ?? "");
checkoutUrl.searchParams.set(
  "metadata",
  JSON.stringify({ user_id: user.id })
);
window.location.href = checkoutUrl.toString();
On the server we simply export the Checkout handler:
src/app/api/checkout/route.ts
import { Checkout } from "@polar-sh/nextjs";

export const GET = Checkout({
  accessToken: process.env.POLAR_ACCESS_TOKEN!,
  successUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/success`,
  server: "sandbox", // Default: sandbox for development
});
Making server environment-driven: The default boilerplate uses "sandbox" for safety. To switch dynamically based on environment, update the handler:
src/app/api/checkout/route.ts (production-ready)
export const GET = Checkout({
  accessToken: process.env.POLAR_ACCESS_TOKEN!,
  successUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/success`,
  server: process.env.POLAR_SANDBOX === "true" ? "sandbox" : "production",
});
Then set POLAR_SANDBOX=false in your production environment so Polar serves live checkout URLs.

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

EventAction
subscription.createdUpserts user_subscriptions using metadata user_id
subscription.updatedKeeps plan, status, billing cycle, cancellation fields in sync
subscription.activeForces status to active after successful payment
subscription.canceledRecords cancel_at_period_end, cancel_at, canceled_at
subscription.revokedImmediately sets status to canceled
order.createdLogged for visibility (no DB writes)
order.paidInserts into payment_history and fetches invoice URL via Polar REST API
order.refundedInserts a negative amount row with status refunded

Key implementation details

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.
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.
If webhook logs show No user ID found, confirm your checkout metadata includes user_id and that the Supabase tables contain the new Polar columns.

Database Schema

After running the migration, the following Polar-specific fields are available:

user_subscriptions

ColumnDescription
polar_customer_idPolar customer identifier (cus_...)
polar_subscription_idPolar subscription ID (sub_...)
polar_product_idProduct ID tied to the plan
billing_cycleDerived from the product (month / year)
cancel_at, cancel_at_period_end, canceled_atCancellation metadata synced from Polar

payment_history

ColumnDescription
polar_subscription_idSubscription associated with the charge
polar_order_idOrder ID emitted by Polar
invoice_urlHosted invoice link returned by the helper
amount, currency, statusMirror Polar’s netAmount, currency, and payment state

polar_products

The migration also creates a polar_products table for caching product metadata:
ColumnTypeDescription
idTEXT (PK)Polar product ID
nameTEXTProduct name
descriptionTEXTProduct description
activeBOOLEANWhether the product is active (default true)
created_at / updated_atTIMESTAMPTZRecord timestamps
Row Level Security is enabled with a read-only policy for authenticated users viewing active products.
You can inspect the full schema inside supabase/migrations/20240201000000_add_polar_columns.sql for the exact SQL.

Testing

Sandbox cards

Card numberUse case
4242 4242 4242 4242Successful payment
4000 0000 0000 0002Declined payment
Use any future expiration date and 3-digit CVC codes.

Checklist

  • Complete a sandbox checkout via /pricing.
  • Confirm /success loads.
  • Visit /dashboard/settings/billing and click Manage Subscription to ensure /api/portal resolves a polar_customer_id.
  • Watch the terminal for subscription.created, order.paid, etc.
  • Verify user_subscriptions now has polar_customer_id + polar_subscription_id.
  • Confirm payment_history contains the amount and invoice URL.
  • Use the Polar customer portal to cancel.
  • Expect subscription.canceledcancel_at_period_end updates, 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:
  1. Create a one-time product in Polar Dashboard → Products → Add Product → Set pricing model to “One-time”
  2. Copy the product ID and add it to your plans.ts configuration
  3. The same /api/checkout endpoint works—Polar automatically detects the product type
No code changes required. Simply pass the one-time product ID to /api/checkout?products=prod_onetime_... and Polar routes the buyer to the appropriate checkout flow.

Custom Trial Periods

Trial periods are configured directly in Polar when creating or editing a product:
  1. Go to Polar Dashboard → Products → Select your subscription product
  2. Enable Free Trial and set the trial duration (e.g., 14 days)
  3. Polar automatically handles trial start/end dates and notifies users before conversion
Webhooks will include 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:
  1. Navigate to Polar Dashboard → Discounts → Create Discount
  2. Set discount type (percentage or fixed amount), duration, and redemption limits
  3. Share the generated code with customers
Customers enter the promo code during Polar Checkout. No additional code changes are needed in Sabo—discounts are applied server-side by Polar.

Troubleshooting

  • Ensure ngrok (or your reverse proxy) is running.
  • Double-check POLAR_WEBHOOK_SECRET matches the dashboard value.
  • Confirm the webhook endpoint returns 200 in Polar’s event log.
/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.
Invoices can take a moment to generate. The helper already retries by triggering generation, but if null persists, re-fetching later usually resolves it.
If checkout keeps pointing at sandbox, set POLAR_SANDBOX=false and redeploy. Also update /api/checkout to pass server: "production" when building the handler.
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.