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

# Polar Payments with Better Auth

> Configure per-user Polar checkout and server-side entitlements in a Better Auth app.

This how-to is for developers adding per-user software as a service (SaaS) billing to a TypeScript Next.js application. It uses the App Router and assumes the application already uses [Better Auth](https://better-auth.com/docs/installation) for authentication. Your goal is to create a server-owned [Polar](https://polar.sh/docs/introduction) checkout and authorize paid access from [Customer State](https://polar.sh/docs/integrate/customer-state). Polar’s [`externalId`](https://polar.sh/docs/api-reference/customers/create#body-external-id) stores your application’s user identifier (ID) on the Polar customer.

This page implements one billing model: one Better Auth user maps to one Polar customer. Do not apply it unchanged to organizations, teams, or seat-based billing.

The request path has four responsibilities:

* **Better Auth**: authenticates the request and supplies the verified user ID
* **Your server**: creates or associates the Polar customer and fixes checkout policy
* **Polar Customer State**: returns active subscriptions and granted benefits
* **Your authorization code**: checks one stable benefit ID before protected work runs

## Use the supported package set

This how-to pins the software development kit (SDK) and adapter versions that its imports target. The clean validation path also pins the framework and TypeScript toolchain.

<Steps>
  <Step title="Create a clean validation application">
    Create a TypeScript App Router application with `create-next-app` `16.2.10`. This exact version is available in the [versioned Next.js package metadata](https://registry.npmjs.org/next/16.2.10). Skip this step when you are adding the integration to an existing application.

    ```sh theme={null}
    npx create-next-app@16.2.10 polar-billing-check \
      --ts --app --src-dir --use-npm \
      --import-alias "@/*" --yes
    cd polar-billing-check
    ```
  </Step>

  <Step title="Install the pinned integration dependencies">
    Install the exact versions used by every snippet on this page. The clean harness also pins React and TypeScript so another reader can reproduce the type check.

    ```sh theme={null}
    npm install next@16.2.10 react@19.2.7 react-dom@19.2.7
    npm install better-auth@1.6.23 @polar-sh/better-auth@1.8.4
    npm install @polar-sh/sdk@0.47.1 better-sqlite3@12.11.1 zod@4.4.3
    npm install --save-dev typescript@5.8.3 @types/node@26.1.1
    npm install --save-dev @types/react@19.2.17 @types/react-dom@19.2.3
    npm install --save-dev @types/better-sqlite3@7.6.13
    ```
  </Step>
</Steps>

Adapter `1.8.4` declares `@polar-sh/sdk` `^0.47.0` and Better Auth `^1.4.12` in its [release manifest](https://github.com/polarsource/polar-adapters/blob/cbd4bd5/packages/polar-betterauth/package.json). Keep the SDK on `0.47.x` while you use this adapter version. The [adapter `1.8.4` release](https://github.com/polarsource/polar-adapters/releases/tag/%40polar-sh%2Fbetter-auth%401.8.4) includes the update to SDK `0.47.0`.

## Choose lazy customer creation before configuring Better Auth

This how-to sets `createCustomerOnSignUp: false` and creates the Polar customer on the first billing action. That policy keeps Polar availability out of signup and makes recovery explicit.

Adapter `1.8.4` uses two eager hooks when `createCustomerOnSignUp` is `true`. It creates or finds a customer before local user creation, then assigns `externalId` after local creation. See the pinned [customer hooks](https://github.com/polarsource/polar-adapters/blob/cbd4bd5/packages/polar-betterauth/src/hooks/customer.ts).

The same adapter also registers a user-deletion hook in the pinned [server integration](https://github.com/polarsource/polar-adapters/blob/cbd4bd5/packages/polar-betterauth/src/server.ts). With eager creation enabled, that hook deletes the matching Polar customer after local deletion. [Polar customer deletion](https://polar.sh/docs/api-reference/customers/delete) immediately cancels active subscriptions, revokes benefits, and clears the external ID. Polar anonymizes personally identifiable information only when deletion uses `anonymize=true`.

Because this page uses lazy creation, the adapter does not perform that automatic customer deletion. Define your account-deletion policy separately before adding remote deletion to your application.

## Prepare Polar and the application

Complete these prerequisites before you add application code. Use separate Polar sandbox and production resources so IDs cannot cross environments.

<Steps>
  <Step title="Create the product and access benefit">
    Create a Polar product for the paid plan. Then create a [Feature Flag benefit](https://polar.sh/docs/features/benefits/feature-flags), which grants application feature access, and attach it to that product.
  </Step>

  <Step title="Create a scoped organization token">
    Create a Polar [Organization Access Token](https://polar.sh/docs/integrate/authentication), which authenticates your server to the Polar application programming interface (API). Grant only the scopes used by this page:

    * `customers:read`
    * `customers:write`
    * `checkouts:read`
    * `checkouts:write`
    * `customer_sessions:write`
  </Step>

  <Step title="Protect signup before billing integration">
    Keep email verification, signup rate limits, and your bot challenge enabled. Lazy creation prevents Polar calls during signup, but these controls still protect account creation.
  </Step>

  <Step title="Add local environment variables">
    Set an explicit billing environment. Preview and staging deployments can set `NODE_ENV=production`, so do not use `NODE_ENV` as the Polar environment selector.
  </Step>
</Steps>

Use your application’s public uniform resource locator (URL) as `APP_URL`. The webhook secret is absent because this how-to does not configure webhooks.

```dotenv theme={null}
BETTER_AUTH_SECRET=your_32_character_or_longer_secret_here
BETTER_AUTH_URL=http://localhost:3000
APP_URL=http://localhost:3000
POLAR_ENVIRONMENT=sandbox
POLAR_ACCESS_TOKEN=your_polar_access_token_here
POLAR_PRO_PRODUCT_ID=12345678-1234-4234-8234-123456789012
POLAR_PRO_BENEFIT_ID=23456789-2345-4345-8345-234567890123
```

## Validate configuration at startup

Validate billing configuration once so route code can use typed values. The shared UUID schema also validates checkout identifiers later in the flow.

```typescript {3-11} theme={null}
// src/lib/validation.ts
import { z } from "zod";

export const uuidSchema = z.string().uuid();

export const billingEnvSchema = z.object({
  APP_URL: z.string().url(),
  POLAR_ENVIRONMENT: z.enum(["sandbox", "production"]),
  POLAR_ACCESS_TOKEN: z.string().min(1),
  POLAR_PRO_PRODUCT_ID: uuidSchema,
  POLAR_PRO_BENEFIT_ID: uuidSchema,
});
```

Parse the environment and require `APP_URL` to be an origin without a path or query.

```typescript {5-12} theme={null}
// src/lib/env.ts
import { billingEnvSchema } from "@/lib/validation";

const parsed = billingEnvSchema.parse(process.env);
const appUrl = new URL(parsed.APP_URL);

if (
  appUrl.username ||
  appUrl.password ||
  appUrl.pathname !== "/" ||
  appUrl.search ||
  appUrl.hash
) {
  throw new Error("APP_URL must contain only an origin");
}

export const env = Object.freeze({
  ...parsed,
  APP_URL: appUrl.origin,
});
```

## Configure Better Auth and Polar

Create one Polar client for server code. Global retries stay disabled so write requests are never replayed automatically.

```typescript {4-9} theme={null}
// src/lib/polar.ts
import { Polar } from "@polar-sh/sdk";

import { env } from "@/lib/env";

export const polarClient = new Polar({
  accessToken: env.POLAR_ACCESS_TOKEN,
  server: env.POLAR_ENVIRONMENT,
  timeoutMs: 3_000,
  retryConfig: { strategy: "none" },
});
```

Configure Better Auth with lazy Polar customer creation. Adapter `1.8.4` parses the portal return URL during initialization, so pass an absolute URL. The behavior is visible in the pinned [portal source](https://github.com/polarsource/polar-adapters/blob/cbd4bd5/packages/polar-betterauth/src/plugins/portal.ts).

```typescript {10-17} theme={null}
// src/lib/auth.ts
import { polar, portal } from "@polar-sh/better-auth";
import Database from "better-sqlite3";
import { betterAuth } from "better-auth";

import { env } from "@/lib/env";
import { polarClient } from "@/lib/polar";

export const auth = betterAuth({
  database: new Database("auth.db"),
  emailAndPassword: { enabled: true },
  plugins: [
    polar({
      client: polarClient,
      createCustomerOnSignUp: false,
      use: [
        portal({
          returnUrl: new URL("/settings/billing", env.APP_URL).toString(),
        }),
      ],
    }),
  ],
});
```

Mount the Better Auth handler at its documented Next.js App Router path. This mount also serves the Polar portal endpoint added by the adapter.

```typescript theme={null}
// src/app/api/auth/[...all]/route.ts
import { toNextJsHandler } from "better-auth/next-js";

import { auth } from "@/lib/auth";

export const { GET, POST } = toNextJsHandler(auth);
```

For a new SQLite-backed Better Auth application, create the required tables before starting the development server. Use the [Better Auth migration command](https://better-auth.com/docs/installation#create-database-tables).

```sh theme={null}
npx auth@latest migrate
```

## Create or associate the customer on the first billing action

The first server-owned billing action must ensure that the signed-in user has one Polar customer. The helper below also recovers a customer that exists by email but lacks an external ID.

Import the generated error classes from their model paths. These paths target the exact [SDK `0.47.1` package](https://registry.npmjs.org/%40polar-sh%2Fsdk/0.47.1), so re-run the validation harness before changing the SDK.

```typescript theme={null}
// src/lib/billing/customer.ts
import { HTTPValidationError } from
  "@polar-sh/sdk/models/errors/httpvalidationerror.js";
import { ResourceNotFound } from
  "@polar-sh/sdk/models/errors/resourcenotfound.js";

import { polarClient } from "@/lib/polar";

export type BillingUser = {
  id: string;
  email: string;
  name: string;
};

export class PolarCustomerConflictError extends Error {}
```

Associate an email-matched customer only when its external ID is empty or already matches the local user. Polar [requires each external ID to be unique and prevents changing it after assignment](https://polar.sh/docs/api-reference/customers/create#body-external-id).

```typescript {8-18} theme={null}
async function associateExistingCustomer(user: BillingUser) {
  const { result } = await polarClient.customers.list({
    email: user.email,
  });

  if (result.items.length === 0) return false;
  if (result.items.length !== 1) {
    throw new PolarCustomerConflictError("Customer email is not unique");
  }

  const customer = result.items[0];
  if (customer.externalId === user.id) return true;
  if (customer.externalId) {
    throw new PolarCustomerConflictError("Customer belongs to another user");
  }

  await polarClient.customers.update({
    id: customer.id,
    customerUpdate: { externalId: user.id },
  });
  return true;
}
```

Create a customer with the local user ID when no safe email match exists. A validation error triggers one lookup retry to handle a concurrent create request.

```typescript {1-5,12-22} theme={null}
export async function ensurePolarCustomer(user: BillingUser) {
  try {
    await polarClient.customers.getStateExternal({ externalId: user.id });
    return;
  } catch (error) {
    if (!(error instanceof ResourceNotFound)) throw error;
  }

  if (await associateExistingCustomer(user)) return;

  try {
    await polarClient.customers.create({
      externalId: user.id,
      email: user.email,
      name: user.name,
    });
  } catch (error) {
    if (!(error instanceof HTTPValidationError)) throw error;
    if (await associateExistingCustomer(user)) return;
    throw error;
  }
}
```

## Add a concrete checkout rate limiter

The local harness uses a fixed-window in-memory limiter so the route is runnable without another service. Replace this file with a shared-store implementation before deploying more than one application process.

```typescript {8-19} theme={null}
// src/lib/billing/rate-limit.ts
const windowMs = 60_000;
const maxAttempts = 5;
const attempts = new Map<string, { count: number; resetAt: number }>();

export function allowBillingAttempt(userId: string) {
  const now = Date.now();
  const current = attempts.get(userId);

  if (!current || current.resetAt <= now) {
    attempts.set(userId, { count: 1, resetAt: now + windowMs });
    return true;
  }

  if (current.count >= maxAttempts) return false;
  current.count += 1;
  return true;
}
```

## Create checkout from a server-owned route

Keep price-sensitive fields on the server. The browser sends only the plan key, while the server sets the product, discount policy, trial policy, and redirect URLs.

```typescript {7-18} theme={null}
// src/lib/billing/checkout.ts
import type { BillingUser } from "@/lib/billing/customer";
import { ensurePolarCustomer } from "@/lib/billing/customer";
import { env } from "@/lib/env";
import { polarClient } from "@/lib/polar";

export async function createProCheckout(user: BillingUser) {
  await ensurePolarCustomer(user);

  return polarClient.checkouts.create({
    products: [env.POLAR_PRO_PRODUCT_ID],
    externalCustomerId: user.id,
    customerEmail: user.email,
    customerName: user.name,
    allowDiscountCodes: false,
    allowTrial: false,
    successUrl: new URL(
      "/billing/finish?checkout_id={CHECKOUT_ID}",
      env.APP_URL,
    ).toString(),
    returnUrl: new URL("/settings/billing", env.APP_URL).toString(),
  });
}
```

The route enforces four server-side controls before it creates a checkout:

* verifies the request origin for cross-site request forgery (CSRF) protection
* verifies the Better Auth session
* accepts only the `pro` plan key
* rate limits each authenticated user

It returns JavaScript Object Notation (JSON) containing only a server-created checkout URL.

```typescript {8-20} theme={null}
// src/app/api/billing/checkout/route.ts
import { auth } from "@/lib/auth";
import { createProCheckout } from "@/lib/billing/checkout";
import { PolarCustomerConflictError } from
  "@/lib/billing/customer";
import { allowBillingAttempt } from "@/lib/billing/rate-limit";
import { env } from "@/lib/env";

export async function POST(request: Request) {
  if (request.headers.get("origin") !== env.APP_URL) {
    return Response.json({ error: "Invalid origin" }, { status: 403 });
  }

  const session = await auth.api.getSession({ headers: request.headers });
  if (!session) {
    return Response.json({ error: "Authentication required" }, { status: 401 });
  }

  const body = (await request.json().catch(() => null)) as
    | { plan?: unknown }
    | null;
  if (body?.plan !== "pro") {
    return Response.json({ error: "Unknown plan" }, { status: 400 });
  }
```

Finish the same route by applying the limiter and handling Polar failures. The handler discards every client field except `plan`.

```typescript {2-17} theme={null}
  if (!allowBillingAttempt(session.user.id)) {
    return Response.json({ error: "Too many attempts" }, { status: 429 });
  }

  try {
    const checkout = await createProCheckout(session.user);
    return Response.json(
      { url: checkout.url },
      { headers: { "Cache-Control": "no-store" } },
    );
  } catch (error) {
    if (error instanceof PolarCustomerConflictError) {
      return Response.json(
        { error: "Billing account conflict" },
        { status: 409 },
      );
    }

    console.error("Unable to create Polar checkout", error);
    return Response.json(
      { error: "Billing is unavailable" },
      { status: 503 },
    );
  }
}
```

Add a client button that prevents duplicate browser submissions while checkout starts. Server validation remains the authorization and pricing boundary.

```tsx theme={null}
// src/components/upgrade-button.tsx
"use client";

import { useState } from "react";

export function UpgradeButton() {
  const [starting, setStarting] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function startCheckout() {
    if (starting) return;
    setStarting(true);
    setError(null);

    const response = await fetch("/api/billing/checkout", {
      method: "POST",
      credentials: "same-origin",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ plan: "pro" }),
    });
```

Handle the response and send the browser only to the server-provided checkout URL.

```tsx {1-13} theme={null}
    const result = (await response.json()) as {
      url?: string;
      error?: string;
    };

    if (!response.ok || !result.url) {
      setError(result.error ?? "Unable to start checkout");
      setStarting(false);
      return;
    }

    window.location.assign(result.url);
  }
```

Render the disabled state and an accessible error message.

```tsx theme={null}
  return (
    <div>
      <button type="button" disabled={starting} onClick={startCheckout}>
        {starting ? "Opening checkout…" : "Upgrade to Pro"}
      </button>
      {error ? <p role="alert">{error}</p> : null}
    </div>
  );
}
```

## Read Customer State and authorize on the server

Customer State returns the current subscriptions and granted benefits for one customer. Treat only a real `404` as “no Polar customer”; keep validation, rate-limit, timeout, and server failures distinct.

```typescript {6-17} theme={null}
// src/lib/billing/state.ts
import type { CustomerState } from
  "@polar-sh/sdk/models/components/customerstate.js";
import { ResourceNotFound } from
  "@polar-sh/sdk/models/errors/resourcenotfound.js";

import { polarClient } from "@/lib/polar";

export async function getBillingState(userId: string) {
  try {
    return await polarClient.customers.getStateExternal({
      externalId: userId,
    });
  } catch (error) {
    if (error instanceof ResourceNotFound) return null;
    throw error;
  }
}

export type BillingState = CustomerState | null;
```

Authorize the stable benefit ID that you attached to the paid product. Product names and benefit metadata can change without changing this identifier.

```typescript {5-9} theme={null}
// src/lib/billing/access.ts
import type { BillingState } from "@/lib/billing/state";
import { env } from "@/lib/env";

export function hasProAccess(state: BillingState) {
  return (
    state?.grantedBenefits.some(
      (benefit) => benefit.benefitId === env.POLAR_PRO_BENEFIT_ID,
    ) ?? false
  );
}
```

A protected server route must derive the user ID from the verified session. Return `503` when Polar cannot answer instead of converting an availability failure into a denied entitlement.

```typescript {7-20} theme={null}
// src/app/api/pro-feature/route.ts
import { auth } from "@/lib/auth";
import { hasProAccess } from "@/lib/billing/access";
import { getBillingState } from "@/lib/billing/state";

export async function POST(request: Request) {
  const session = await auth.api.getSession({ headers: request.headers });
  if (!session) {
    return Response.json({ error: "Authentication required" }, { status: 401 });
  }

  try {
    const state = await getBillingState(session.user.id);
    if (!hasProAccess(state)) {
      return Response.json({ error: "Pro plan required" }, { status: 403 });
    }
  } catch (error) {
    console.error("Unable to verify Polar entitlement", error);
    return Response.json({ error: "Entitlement unavailable" }, { status: 503 });
  }

  return Response.json({ ok: true });
}
```

## Route checkout completion from verified state

The success URL does not grant access. The application must verify the checkout identifier, the signed-in user, checkout ownership, checkout status, and Customer State.

```mermaid theme={null}
stateDiagram-v2
    [*] --> CheckCheckout
    CheckCheckout --> Resume: open
    CheckCheckout --> Pending: confirmed
    CheckCheckout --> Failed: failed or expired
    CheckCheckout --> CheckBenefit: succeeded
    CheckBenefit --> Ready: expected benefit granted
    CheckBenefit --> Pending: benefit not visible yet
    Resume --> PolarCheckout
    Pending --> CheckCheckout: bounded retry
    Ready --> App
    Failed --> BillingSettings
```

The status endpoint returns one of four explicit results:

| Checkout and Customer State      | Response                                             |
| -------------------------------- | ---------------------------------------------------- |
| `open`                           | `{ state: "resume", location: checkout.url }`        |
| `confirmed`                      | `{ state: "pending" }`                               |
| `succeeded` with the Pro benefit | `{ state: "ready", location: "/app" }`               |
| `succeeded` without the benefit  | `{ state: "pending" }`                               |
| `failed` or `expired`            | `{ state: "failed", location: "/settings/billing" }` |

Define the response type next to the resolver so the browser and server share one contract.

```typescript theme={null}
// src/lib/billing/status.ts
import { polarClient } from "@/lib/polar";
import { hasProAccess } from "@/lib/billing/access";
import { getBillingState } from "@/lib/billing/state";

export type BillingStatus =
  | { state: "pending" }
  | { state: "ready" | "resume" | "failed"; location: string };

export class CheckoutOwnershipError extends Error {}

export async function resolveBillingStatus(
  userId: string,
  checkoutId: string,
): Promise<BillingStatus> {
  const checkout = await polarClient.checkouts.get({ id: checkoutId });
  if (checkout.externalCustomerId !== userId) {
    throw new CheckoutOwnershipError();
  }
```

Route each [checkout status](https://polar.sh/docs/api-reference/checkouts/get-session) explicitly. Clicking **Pay** can produce `confirmed`, but only `succeeded` confirms checkout completion.

```typescript {1-19} theme={null}
  if (checkout.status === "open") {
    return { state: "resume", location: checkout.url };
  }

  if (checkout.status === "confirmed") {
    return { state: "pending" };
  }

  if (checkout.status === "failed" || checkout.status === "expired") {
    return { state: "failed", location: "/settings/billing" };
  }

  if (checkout.status === "succeeded") {
    const state = await getBillingState(userId);
    if (hasProAccess(state)) {
      return { state: "ready", location: "/app" };
    }
  }

  return { state: "pending" };
}
```

Validate the universally unique identifier (UUID) before calling Polar. Return `404` for a missing checkout or an ownership mismatch, and `503` for other Polar failures.

```typescript {9-22} theme={null}
// src/app/api/billing/status/route.ts
import { ResourceNotFound } from
  "@polar-sh/sdk/models/errors/resourcenotfound.js";

import { auth } from "@/lib/auth";
import {
  CheckoutOwnershipError,
  resolveBillingStatus,
} from "@/lib/billing/status";
import { uuidSchema } from "@/lib/validation";

export async function GET(request: Request) {
  const session = await auth.api.getSession({ headers: request.headers });
  if (!session) {
    return Response.json({ error: "Authentication required" }, { status: 401 });
  }

  const value = new URL(request.url).searchParams.get("checkout_id");
  const parsed = uuidSchema.safeParse(value);
  if (!parsed.success) {
    return Response.json({ error: "Invalid checkout ID" }, { status: 400 });
  }
```

Resolve the status without exposing another user’s checkout or accepting browser-provided destination URLs.

```typescript {2-16} theme={null}
  try {
    const result = await resolveBillingStatus(session.user.id, parsed.data);
    return Response.json(result, {
      headers: { "Cache-Control": "no-store" },
    });
  } catch (error) {
    if (
      error instanceof ResourceNotFound ||
      error instanceof CheckoutOwnershipError
    ) {
      return Response.json({ error: "Checkout not found" }, { status: 404 });
    }

    console.error("Unable to resolve checkout status", error);
    return Response.json({ error: "Billing is unavailable" }, { status: 503 });
  }
}
```

## Add a bounded finishing page

The finishing page polls only while the status is `pending`. It stops after 30s and lets the customer retry or return to billing settings.

```tsx theme={null}
// src/app/billing/finish/page.tsx
import { FinishingBilling } from "@/components/finishing-billing";

export default async function BillingFinishPage({
  searchParams,
}: {
  searchParams: Promise<{ checkout_id?: string }>;
}) {
  const { checkout_id: checkoutId } = await searchParams;
  return <FinishingBilling checkoutId={checkoutId ?? ""} />;
}
```

Define the response contract again in the client file so readers do not need to search backward for its shape.

```tsx theme={null}
// src/components/finishing-billing.tsx
"use client";

import { useEffect, useState } from "react";

type BillingStatus =
  | { state: "pending" }
  | { state: "ready" | "resume" | "failed"; location: string };

const sleep = (milliseconds: number) =>
  new Promise((resolve) => setTimeout(resolve, milliseconds));
```

Poll with bounded exponential backoff. The browser follows only destinations returned by the server-owned status route.

```tsx {8-22} theme={null}
async function pollBillingStatus(checkoutId: string) {
  const deadline = Date.now() + 30_000;
  let delay = 500;

  while (Date.now() < deadline) {
    const query = new URLSearchParams({ checkout_id: checkoutId });
    const response = await fetch(`/api/billing/status?${query}`, {
      credentials: "same-origin",
      cache: "no-store",
    });

    if (response.ok) {
      const result = (await response.json()) as BillingStatus;
      if (result.state !== "pending") return result;
    } else if (response.status !== 503) {
      throw new Error("Unable to verify checkout");
    }

    await sleep(delay);
    delay = Math.min(delay * 2, 5_000);
  }

  return null;
}
```

Run the poll when the component mounts or the customer clicks **Try again**.

```tsx {8-22} theme={null}
export function FinishingBilling({ checkoutId }: { checkoutId: string }) {
  const [attempt, setAttempt] = useState(0);
  const [message, setMessage] = useState("Verifying payment…");

  useEffect(() => {
    let active = true;

    void pollBillingStatus(checkoutId)
      .then((result) => {
        if (!active) return;
        if (result) window.location.assign(result.location);
        else setMessage("Payment is taking longer than expected.");
      })
      .catch(() => {
        if (active) setMessage("We could not verify this checkout.");
      });

    return () => {
      active = false;
    };
  }, [attempt, checkoutId]);
```

Render a finite recovery path instead of an endless loading state.

```tsx theme={null}
  return (
    <div>
      <p role="status">{message}</p>
      <button type="button" onClick={() => setAttempt((value) => value + 1)}>
        Try again
      </button>
      <a href="/settings/billing">Back to billing</a>
    </div>
  );
}
```

## Add the customer portal client

The portal plugin adds authenticated customer methods to the Better Auth client. This client lets a customer open Polar’s hosted billing portal after a Polar customer exists.

```typescript {4-8} theme={null}
// src/lib/auth-client.ts
import { polarClient } from "@polar-sh/better-auth/client";
import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient({
  plugins: [polarClient()],
});
```

Call the portal method from an authenticated client component after Customer State exists. Hide this button before the first billing action creates or associates the Polar customer.

```tsx theme={null}
// src/components/manage-billing-button.tsx
"use client";

import { authClient } from "@/lib/auth-client";

export function ManageBillingButton() {
  return (
    <button
      type="button"
      onClick={() => void authClient.customer.portal()}
    >
      Manage billing
    </button>
  );
}
```

Webhooks are not required for this implementation because every authorization decision reads Customer State directly. Add [Polar webhooks](https://polar.sh/docs/integrate/webhooks/endpoints) only when another feature requires asynchronous delivery.

## Validate the integration

The pinned setup at the start of this page is the local type-check harness. Copy every snippet into its documented file path, add the environment variables, and add this script to `package.json`.

```sh theme={null}
npm pkg set scripts.typecheck="tsc --noEmit"
npm run typecheck
```

Before publishing or deploying the integration, verify this path end to end:

* [ ] Install the pinned Better Auth, adapter, and SDK versions from a clean lockfile
* [ ] Run the Better Auth database migration and start the Next.js application
* [ ] Sign in, start checkout, and verify the server ignores extra browser fields
* [ ] Confirm a new billing action creates one customer with the local user ID as `externalId`
* [ ] Confirm an email-matched customer with no external ID becomes associated once
* [ ] Reject a customer whose existing external ID belongs to another local user
* [ ] Return `401` without a valid session and `429` after five checkout attempts per minute
* [ ] Reject malformed checkout IDs and another user’s checkout with `404`
* [ ] Route `open`, `confirmed`, `succeeded`, `failed`, and `expired` checkout states as documented
* [ ] Grant protected access only after Customer State contains `POLAR_PRO_BENEFIT_ID`
* [ ] Return `503` when Polar cannot provide the entitlement decision
* [ ] Verify the 30s finishing flow stops polling and offers a retry path
* [ ] Run `npm run typecheck` with every published TypeScript and TSX snippet in place
