kitcn

Plugins

Polar

Payments and subscriptions with Polar and Better Auth.

Overview

Payment and subscription system with Better Auth's Polar plugin:

FeatureDescription
CheckoutPayment flows
SubscriptionsRecurring billing management
PortalCustomer self-service
Usage billingEvent-based metering
WebhooksReal-time billing events

Prerequisites

1. Install Dependencies

npm install @polar-sh/better-auth @polar-sh/sdk buffer
pnpm add @polar-sh/better-auth @polar-sh/sdk buffer
yarn add @polar-sh/better-auth @polar-sh/sdk buffer
bun add @polar-sh/better-auth @polar-sh/sdk buffer

2. Server Configuration

Polyfills (REQUIRED)

Convex environment requires Buffer polyfill for Polar SDK:

convex/lib/polar-polyfills.ts
import { Buffer as BufferPolyfill } from 'buffer';

globalThis.Buffer = BufferPolyfill;

Polar Client Helper

convex/lib/polar-client.ts
import { Polar } from '@polar-sh/sdk';

export const getPolarClient = () =>
  new Polar({
    accessToken: process.env.POLAR_ACCESS_TOKEN!,
    server:
      process.env.POLAR_SERVER === 'production' ? 'production' : 'sandbox',
  });

Better Auth with Polar Plugin

convex/functions/auth.ts
// IMPORTANT: Import polyfills FIRST
import '../lib/polar-polyfills';

import { checkout, polar, portal, usage, webhooks } from '@polar-sh/better-auth';
import { Polar } from '@polar-sh/sdk';
import { createPolarCustomerCaller } from './generated/polarCustomer.runtime';
import { createPolarSubscriptionCaller } from './generated/polarSubscription.runtime';
import { defineAuth } from './generated/auth';

export default defineAuth((ctx) => ({
    // ... existing config
    plugins: [
      polar({
        client: new Polar({
          accessToken: process.env.POLAR_ACCESS_TOKEN!,
          server:
            process.env.POLAR_SERVER === 'production' ? 'production' : 'sandbox',
        }),
        // Customer creation via scheduler (recommended for Convex)
        // createCustomerOnSignUp: true, // Use trigger instead
        use: [
          checkout({
            authenticatedUsersOnly: true,
            products: [
              { productId: process.env.POLAR_PRODUCT_PREMIUM!, slug: 'premium' },
            ],
            successUrl: `${process.env.SITE_URL}/success?checkout_id={CHECKOUT_ID}`,
            theme: 'light',
          }),
          portal(),
          usage(),
          webhooks({
            secret: process.env.POLAR_WEBHOOK_SECRET!,
            onCustomerCreated: async (payload) => {
              const userId = payload?.data.externalId;
              if (!userId) return;

              const caller = createPolarCustomerCaller(ctx);
              await caller.updateUserPolarCustomerId({
                customerId: payload.data.id, userId,
              });
            },
            onSubscriptionCreated: async (payload) => {
              if (!payload.data.customer.externalId) return;

              const caller = createPolarSubscriptionCaller(ctx);
              await caller.createSubscription({
                subscription: convertToDatabaseSubscription(payload.data),
              });
            },
            onSubscriptionUpdated: async (payload) => {
              if (!payload.data.customer.externalId) return;

              const caller = createPolarSubscriptionCaller(ctx);
              await caller.updateSubscription({
                subscription: convertToDatabaseSubscription(payload.data),
              });
            },
          }),
        ],
      }),
    ],
  }));

Customer Creation via Trigger

Create Polar customer asynchronously when user signs up:

convex/functions/auth.ts
import { defineAuth } from './generated/auth';
import { createPolarCustomerCaller } from './generated/polarCustomer.runtime';

export default defineAuth((ctx) => ({
  // ... config
  triggers: {
    user: {
      create: {
        after: async (user, triggerCtx) => {
          // Create Polar customer via caller from defineAuth closure ctx
          const caller = createPolarCustomerCaller(ctx);
          await caller.schedule.now.createCustomer({
            email: user.email,
            name: user.name || user.username,
            userId: user.id,
          });
        },
      },
    },
  },
}));

3. Client Configuration

src/lib/convex/auth-client.ts
import { createAuthClient } from 'better-auth/react';
import { polarClient } from '@polar-sh/better-auth';

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

4. Schema

convex/functions/schema.ts
import { boolean, convexTable, defineSchema, id, index, integer, json, text } from 'kitcn/orm';

// User table - add Polar customer ID
export const user = convexTable(
  'user',
  {
    // ... existing fields
    customerId: text(), // Polar customer ID
  },
  (t) => [index('customerId').on(t.customerId)]
);

// Subscriptions table - organization-based
export const subscriptions = convexTable(
  'subscriptions',
  {
    subscriptionId: text().notNull(),
    organizationId: text().notNull(),
    userId: id('user').notNull(),
    productId: text().notNull(),
    priceId: text(),
    status: text().notNull(), // 'active', 'canceled', 'trialing', 'past_due'
    amount: integer(),
    currency: text(),
    recurringInterval: text(),
    currentPeriodStart: text().notNull(),
    currentPeriodEnd: text(),
    cancelAtPeriodEnd: boolean().notNull(),
    startedAt: text(),
    endedAt: text(),
    createdAt: text().notNull(),
    modifiedAt: text(),
    checkoutId: text(),
    metadata: json<Record<string, unknown>>(),
    customerCancellationReason: text(),
    customerCancellationComment: text(),
  },
  (t) => [
    index('subscriptionId').on(t.subscriptionId),
    index('organizationId').on(t.organizationId),
    index('organizationId_status').on(t.organizationId, t.status),
    index('userId_status').on(t.userId, t.status),
  ]
);

export const tables = { user, subscriptions };
export default defineSchema(tables, { strict: false });

5. Helper Functions

Subscription Conversion Helper

convex/lib/polar-helpers.ts
import type { Subscription } from '@polar-sh/sdk/models/components/subscription';
import type { WithoutSystemFields } from 'convex/server';
import type { Doc, Id } from '../functions/_generated/dataModel';

export const convertToDatabaseSubscription = (
  subscription: Subscription
): WithoutSystemFields<Doc<'subscriptions'>> => {
  // Extract organizationId from subscription metadata (referenceId)
  const organizationId = subscription.metadata
    ?.referenceId as Id<'organization'>;

  if (!organizationId) {
    throw new Error(
      'Subscription missing organizationId in metadata.referenceId'
    );
  }

  return {
    amount: subscription.amount,
    cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
    checkoutId: subscription.checkoutId,
    createdAt: subscription.createdAt.toISOString(),
    currency: subscription.currency,
    currentPeriodEnd: subscription.currentPeriodEnd?.toISOString() ?? null,
    currentPeriodStart: subscription.currentPeriodStart.toISOString(),
    customerCancellationComment: subscription.customerCancellationComment,
    customerCancellationReason: subscription.customerCancellationReason,
    endedAt: subscription.endedAt?.toISOString() ?? null,
    metadata: subscription.metadata ?? {},
    modifiedAt: subscription.modifiedAt?.toISOString() ?? null,
    organizationId,
    productId: subscription.productId,
    recurringInterval: subscription.recurringInterval,
    startedAt: subscription.startedAt?.toISOString() ?? null,
    status: subscription.status,
    subscriptionId: subscription.id,
    // IMPORTANT: Use externalId, not metadata.userId
    userId: subscription.customer.externalId as Id<'user'>,
  };
};

Checkout Plugin

Configure checkout:

convex/functions/auth.ts
checkout({
  products: [
    { productId: 'uuid-from-polar', slug: 'pro' },
    { productId: 'uuid-from-polar', slug: 'enterprise' },
  ],
  successUrl: `${process.env.SITE_URL}/success?checkout_id={CHECKOUT_ID}`,
  returnUrl: `${process.env.SITE_URL}`, // Optional back button
  authenticatedUsersOnly: true,
  theme: 'light', // or 'dark'
}),

Client Checkout

// Using product slug
await authClient.checkout({
  slug: 'pro',
  referenceId: organizationId, // Links subscription to organization
});

// Using product ID directly
await authClient.checkout({
  products: ['e651f46d-ac20-4f26-b769-ad088b123df2'],
  referenceId: organizationId,
});

Organization-Based Checkout

src/components/checkout-button.tsx
const handleSubscribe = async () => {
  const activeOrganizationId = user.activeOrganization?.id;

  if (!activeOrganizationId) {
    toast.error('Please select an organization');
    return;
  }

  try {
    if (currentUser.plan) {
      // User has plan - open portal to manage
      await authClient.customer.portal();
    } else {
      // No plan - initiate checkout
      await authClient.checkout({
        slug: 'premium',
        referenceId: activeOrganizationId,
      });
    }
  } catch (error) {
    console.error('Polar checkout error:', error);
    toast.error('Failed to open checkout');
  }
};

Portal Plugin

Customer self-service portal for managing subscriptions:

Open Portal

await authClient.customer.portal();

Customer State

const { data: customerState } = await authClient.customer.state();

// Contains:
// - Customer data
// - Active subscriptions
// - Granted benefits
// - Active meters with balance

List Benefits

const { data: benefits } = await authClient.customer.benefits.list({
  query: { page: 1, limit: 10 },
});

List Orders

const { data: orders } = await authClient.customer.orders.list({
  query: {
    page: 1,
    limit: 10,
    productBillingType: 'one_time', // or 'recurring'
  },
});

List Subscriptions

const { data: subscriptions } = await authClient.customer.subscriptions.list({
  query: {
    page: 1,
    limit: 10,
    active: true,
  },
});

Organization Subscriptions

const organizationId = (await authClient.organization.list())?.data?.[0]?.id;

const { data: subscriptions } = await authClient.customer.orders.list({
  query: {
    page: 1,
    limit: 10,
    active: true,
    referenceId: organizationId,
  },
});

const userShouldHaveAccess = subscriptions.some(
  (sub) => sub.productId === process.env.NEXT_PUBLIC_POLAR_PRODUCT_PREMIUM
);

Usage Plugin

Usage-based billing with event ingestion:

Event Ingestion

const { data: ingested } = await authClient.usage.ingestion({
  event: 'file-uploads',
  metadata: {
    uploadedFiles: 12,
    totalSizeBytes: 1024000,
  },
});

Customer Meters

const { data: customerMeters } = await authClient.usage.meters.list({
  query: { page: 1, limit: 10 },
});

// Returns:
// - Customer information
// - Meter configuration
// - Consumed units, credited units, balance

Webhooks Plugin

The webhooks() plugin verifies Polar webhook signatures and routes events to typed handler callbacks. Pass it your POLAR_WEBHOOK_SECRET and implement any of the ~20 available event handlers.

For the full list of webhook events, see API Reference below.

Convex Functions

Function examples use the ORM (ctx.orm).

Customer Management

convex/functions/polarCustomer.ts
import '../lib/polar-polyfills';

import { CRPCError } from 'kitcn/server';
import { z } from 'zod';
import { privateAction, privateMutation } from '../lib/crpc';
import { getPolarClient } from '../lib/polar-client';

// Create Polar customer (called from user.create.after trigger)
export const createCustomer = privateAction
  .input(
    z.object({
      email: z.string().email(),
      name: z.string().optional(),
      userId: z.string(),
    })
  )

  .action(async ({ input: args }) => {
    const polar = getPolarClient();

    try {
      await polar.customers.create({
        email: args.email,
        externalId: args.userId, // Links Polar customer to Convex user
        name: args.name,
      });
    } catch (error) {
      console.error('Failed to create Polar customer:', error);
    }

    return null;
  });

Build additional subscription management functions (create, update, cancel, resume) following this pattern. The key conventions:

  • Use privateMutation for webhook-triggered writes (subscription create/update)
  • Use privateQuery for internal reads (get active subscription)
  • Use authAction for user-facing operations that call the Polar SDK (cancel/resume)
  • Use callers to bridge between actions and queries/mutations

See the example app for complete subscription CRUD implementations.

Customer Deletion Sync

Sync user deletion with Polar:

convex/functions/auth.ts
import { defineAuth } from './generated/auth';

export default defineAuth((ctx) => ({
    user: {
      deleteUser: {
        enabled: true,
        afterDelete: async (user) => {
          const polar = getPolarClient();
          await polar.customers.deleteExternal({
            externalId: user.id,
          });
        },
      },
    },
  }));

Environment Variables

convex/.env
# Polar API Configuration
POLAR_SERVER="sandbox"                 # 'production' | 'sandbox'
POLAR_ACCESS_TOKEN="polar_at_..."      # Organization access token
POLAR_WEBHOOK_SECRET="whsec_..."       # Webhook signature secret

# Polar Product IDs (from Polar dashboard)
POLAR_PRODUCT_PREMIUM="uuid-here"      # Premium subscription product

Local Development with Ngrok

Polar webhooks require a public URL. Use ngrok to expose your local server:

Setup

  1. Install ngrok: macOS setup guide
  2. Reserve a free static domain in the ngrok dashboard
  3. Add scripts to package.json:
package.json
{
  "scripts": {
    "dev": "concurrently 'next dev' 'bun ngrok'",
    "ngrok": "ngrok http --url=your-domain.ngrok-free.app 3000"
  }
}
  1. Configure the webhook URL in Polar Dashboard → Settings → Webhooks:
https://your-domain.ngrok-free.app/api/auth/polar/webhooks

Common Patterns

Check Organization Subscription

const subscription = await ctx.orm.query.subscriptions.findFirst({
  where: { organizationId, status: 'active' },
});

const isActive = subscription?.status === 'active';

Check User Subscription

const subscription = await ctx.orm.query.subscriptions.findFirst({
  where: { userId, status: 'active' },
});

const isPremium = !!subscription;

API Reference

Webhook Events

All events are passed to webhooks({ secret, ...handlers }) inside the use: [] array of the polar() plugin.

convex/functions/auth.ts
webhooks({
  secret: process.env.POLAR_WEBHOOK_SECRET!,

  // Checkout
  onCheckoutCreated: (payload) => {},
  onCheckoutUpdated: (payload) => {},

  // Orders
  onOrderCreated: (payload) => {},
  onOrderPaid: (payload) => {},
  onOrderRefunded: (payload) => {},

  // Refunds
  onRefundCreated: (payload) => {},
  onRefundUpdated: (payload) => {},

  // Subscriptions
  onSubscriptionCreated: (payload) => {},
  onSubscriptionUpdated: (payload) => {},
  onSubscriptionActive: (payload) => {},
  onSubscriptionCanceled: (payload) => {},
  onSubscriptionRevoked: (payload) => {},
  onSubscriptionUncanceled: (payload) => {},

  // Products
  onProductCreated: (payload) => {},
  onProductUpdated: (payload) => {},

  // Benefits
  onBenefitCreated: (payload) => {},
  onBenefitUpdated: (payload) => {},
  onBenefitGrantCreated: (payload) => {},
  onBenefitGrantUpdated: (payload) => {},
  onBenefitGrantRevoked: (payload) => {},

  // Customers
  onCustomerCreated: (payload) => {},
  onCustomerUpdated: (payload) => {},
  onCustomerDeleted: (payload) => {},
  onCustomerStateChanged: (payload) => {},

  // Catch-all
  onPayload: (payload) => {},
}),

Operations

OperationMethodType
CheckoutauthClient.checkoutClient
Customer portalauthClient.customer.portalClient
Customer stateauthClient.customer.stateClient
List benefitsauthClient.customer.benefits.listClient
List ordersauthClient.customer.orders.listClient
List subscriptionsauthClient.customer.subscriptions.listClient
Event ingestionauthClient.usage.ingestionClient
List metersauthClient.usage.meters.listClient
Create customerinternal.polarCustomer.createCustomerInternal action
Link customer IDinternal.polarCustomer.updateUserPolarCustomerIdInternal mutation
Create subscriptioninternal.polarSubscription.createSubscriptionInternal mutation
Update subscriptioninternal.polarSubscription.updateSubscriptionInternal mutation
Cancel subscriptionConvex actionUser action
Resume subscriptionConvex actionUser action

Use Better Auth client API for checkout and portal operations. Use internal Convex functions for webhook handlers. Use user actions for subscription management.

Next Steps

On this page