> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getsabo.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Payments with Polar

> Complete guide to integrating Polar subscriptions, managing plans, and handling webhooks in Sabo.

<Info>
  Sabo ships with a full Polar integration using the official `@polar-sh/nextjs` SDK. This guide covers everything from environment setup to webhook processing.
</Info>

## 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

<Steps>
  <Step title="Create Polar access tokens">
    Set up your Polar workspace.

    1. Visit [Polar Dashboard](https://polar.sh) 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.

    <Warning>
      Access tokens are secret. Store them only in server-side environment variables.
    </Warning>
  </Step>

  <Step title="Add environment variables">
    Update `.env.local` with Polar settings:

    ```bash .env.local theme={null}
    # 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
    ```

    <Tip>
      `POLAR_SANDBOX=true` switches the webhook helper to `https://sandbox-api.polar.sh`. Leave it enabled for local development.
    </Tip>
  </Step>

  <Step title="Apply database migrations">
    Polar adds new columns for customer + order tracking.

    ```bash theme={null}
    # 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.
  </Step>

  <Step title="Expose webhooks">
    Use ngrok (or any tunneling tool) so Polar can reach your local `/api/webhooks/polar` endpoint.

    ```bash theme={null}
    # 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`
  </Step>

  <Step title="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.

    <Check>
      Watch your terminal for `subscription.*` and `order.*` webhook logs. Each should return `200 OK`.
    </Check>
  </Step>
</Steps>

***

## Configuration

### Plans

`src/lib/payments/plans.ts` stores Polar product IDs for each plan:

```ts src/lib/payments/plans.ts (excerpt) theme={null}
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:

```ts src/lib/payments/plans.ts (helpers) theme={null}
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

| 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:

```tsx src/components/marketing/pricing.tsx (excerpt) theme={null}
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:

```ts src/app/api/checkout/route.ts theme={null}
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
});
```

<Tip>
  **Making `server` environment-driven:** The default boilerplate uses `"sandbox"` for safety. To switch dynamically based on environment, update the handler:

  ```ts src/app/api/checkout/route.ts (production-ready) theme={null}
  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.
</Tip>

### 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

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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`.
  </Accordion>
</AccordionGroup>

<Warning>
  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.
</Warning>

***

## 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                              |

Row Level Security is enabled with a read-only policy for authenticated users viewing active products.

<Check>
  You can inspect the full schema inside `supabase/migrations/20240201000000_add_polar_columns.sql` for the exact SQL.
</Check>

***

## Testing

### Sandbox cards

| Card number           | Use case           |
| --------------------- | ------------------ |
| `4242 4242 4242 4242` | Successful payment |
| `4000 0000 0000 0002` | Declined payment   |

Use any future expiration date and 3-digit CVC codes.

### Checklist

<AccordionGroup>
  <Accordion title="Checkout + portal">
    * 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`.
  </Accordion>

  <Accordion title="Webhook processing">
    * 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.
  </Accordion>

  <Accordion title="Cancellation flow">
    * Use the Polar customer portal to cancel.
    * Expect `subscription.canceled` → `cancel_at_period_end` updates, and status changes propagate to the billing UI.
  </Accordion>
</AccordionGroup>

***

## Production Deployment

<Steps>
  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

***

## 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

<Tip>
  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.
</Tip>

### 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.

<Note>
  Trial configuration lives in Polar, not in code. This keeps pricing logic centralized and easy to update without redeploying.
</Note>

### 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

<AccordionGroup>
  <Accordion title="Webhooks not firing">
    * 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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>
