BETTER-CONVEX

Polar

Payments and subscriptions with Polar and Better Auth.

In this guide, we'll explore payments and subscriptions with better-convex and Polar. You'll learn to configure the Polar plugin, handle checkout flows, manage subscriptions, and process webhooks for billing events.

Overview

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

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

Let's set up Polar integration step by step.

Prerequisites

1. Install Dependencies

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 type { ActionCtx } from './_generated/server';
import { internal } from './_generated/api';

const createAuthOptions = (ctx: GenericCtx) =>
  ({
    // ... 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;

              await (ctx as ActionCtx).runMutation(
                internal.polarCustomer.updateUserPolarCustomerId,
                { customerId: payload.data.id, userId }
              );
            },
            onSubscriptionCreated: async (payload) => {
              if (!payload.data.customer.externalId) return;

              await (ctx as ActionCtx).runMutation(
                internal.polarSubscription.createSubscription,
                { subscription: convertToDatabaseSubscription(payload.data) }
              );
            },
            onSubscriptionUpdated: async (payload) => {
              if (!payload.data.customer.externalId) return;

              await (ctx as ActionCtx).runMutation(
                internal.polarSubscription.updateSubscription,
                { subscription: convertToDatabaseSubscription(payload.data) }
              );
            },
          }),
        ],
      }),
    ],
  }) satisfies BetterAuthOptions;

Customer Creation via Trigger

Create Polar customer asynchronously when user signs up:

convex/functions/auth.ts
export const authClient = createClient<DataModel, typeof schema>({
  // ... config
  triggers: {
    user: {
      onCreate: async (ctx, user) => {
        // Create Polar customer via scheduler
        await ctx.scheduler.runAfter(0, internal.polarCustomer.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 { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';

export default defineSchema({
  // User table - add Polar customer ID
  user: defineTable({
    // ... existing fields
    customerId: v.optional(v.string()), // Polar customer ID
  }).index('customerId', ['customerId']),

  // Subscriptions table - organization-based
  subscriptions: defineTable({
    subscriptionId: v.string(),
    organizationId: v.string(),
    userId: v.id('user'),
    productId: v.string(),
    priceId: v.optional(v.string()),
    status: v.string(), // 'active', 'canceled', 'trialing', 'past_due'
    amount: v.optional(v.number()),
    currency: v.optional(v.string()),
    recurringInterval: v.optional(v.string()),
    currentPeriodStart: v.string(),
    currentPeriodEnd: v.optional(v.string()),
    cancelAtPeriodEnd: v.boolean(),
    startedAt: v.optional(v.string()),
    endedAt: v.optional(v.string()),
    createdAt: v.string(),
    modifiedAt: v.optional(v.string()),
    checkoutId: v.optional(v.string()),
    metadata: v.record(v.string(), v.any()),
    customerCancellationReason: v.optional(v.string()),
    customerCancellationComment: v.optional(v.string()),
  })
    .index('subscriptionId', ['subscriptionId'])
    .index('organizationId', ['organizationId'])
    .index('organizationId_status', ['organizationId', 'status']),
});
convex/functions/schema.ts
import { v } from 'convex/values';
import { defineEnt, defineEntSchema } from 'convex-ents';

const schema = defineEntSchema({
  // User table - add Polar customer ID
  user: defineEnt({
    // ... existing fields
  })
    .field('customerId', v.optional(v.string()), { index: true })
    .edges('subscriptions', { to: 'subscriptions', ref: 'userId' }),

  // Organization table - add subscription edge
  organization: defineEnt({
    // ... existing fields
  }).edge('subscription', { to: 'subscriptions', ref: true }),

  // Subscriptions table - organization-based
  subscriptions: defineEnt({
    amount: v.optional(v.union(v.number(), v.null())),
    cancelAtPeriodEnd: v.boolean(),
    checkoutId: v.optional(v.union(v.string(), v.null())),
    createdAt: v.string(),
    currency: v.optional(v.union(v.string(), v.null())),
    currentPeriodEnd: v.optional(v.union(v.string(), v.null())),
    currentPeriodStart: v.string(),
    customerCancellationComment: v.optional(v.union(v.string(), v.null())),
    customerCancellationReason: v.optional(v.union(v.string(), v.null())),
    endedAt: v.optional(v.union(v.string(), v.null())),
    metadata: v.record(v.string(), v.any()),
    modifiedAt: v.optional(v.union(v.string(), v.null())),
    priceId: v.optional(v.string()),
    productId: v.string(),
    recurringInterval: v.optional(v.union(v.string(), v.null())),
    startedAt: v.optional(v.union(v.string(), v.null())),
    status: v.string(),
  })
    .field('subscriptionId', v.string(), { unique: true })
    .edge('organization', { to: 'organization', field: 'organizationId' })
    .edge('user', { to: 'user', field: 'userId' })
    .index('organizationId_status', ['organizationId', 'status']),
});

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

Enable seamless checkout integration:

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

Handle all Polar webhook events:

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) => {},
}),

Convex Functions

Function examples use ctx.table (Convex Ents). For ctx.db, replace edge traversal with manual joins. See Ents for migration patterns.

Customer Management

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

import { CRPCError } from 'better-convex/server';
import { zid } from 'convex-helpers/server/zod4';
import { z } from 'zod';
import { privateAction, privateMutation } from '../lib/crpc';
import { getPolarClient } from '../lib/polar-client';

// Create Polar customer (called from user.onCreate trigger)
export const createCustomer = privateAction
  .input(
    z.object({
      email: z.string().email(),
      name: z.string().optional(),
      userId: zid('user'),
    })
  )
  .output(z.null())
  .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;
  });

// Link Polar customer ID to user (called from webhook)
export const updateUserPolarCustomerId = privateMutation
  .input(
    z.object({
      customerId: z.string(),
      userId: zid('user'),
    })
  )
  .output(z.null())
  .mutation(async ({ ctx, input: args }) => {
    const user = await ctx.table('user').getX(args.userId);

    // Check for duplicate customer IDs
    const existingUser = await ctx
      .table('user')
      .get('customerId', args.customerId);

    if (existingUser && existingUser._id !== args.userId) {
      throw new CRPCError({
        code: 'CONFLICT',
        message: `Another user already has Polar customer ID ${args.customerId}`,
      });
    }

    await user.patch({ customerId: args.customerId });
    return null;
  });

Subscription Management

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

import { CRPCError } from 'better-convex/server';
import { zid } from 'convex-helpers/server/zod4';
import { z } from 'zod';
import { authAction, privateMutation, privateQuery } from '../lib/crpc';
import { getPolarClient } from '../lib/polar-client';
import { internal } from './_generated/api';

// Create subscription (called from webhook)
export const createSubscription = privateMutation
  .input(z.object({ subscription: subscriptionSchema }))
  .output(z.null())
  .mutation(async ({ ctx, input: args }) => {
    const existing = await ctx
      .table('subscriptions')
      .get('subscriptionId', args.subscription.subscriptionId);

    if (existing) {
      throw new CRPCError({
        code: 'CONFLICT',
        message: `Subscription ${args.subscription.subscriptionId} already exists`,
      });
    }

    await ctx.table('subscriptions').insert(args.subscription);
    return null;
  });

// Update subscription (called from webhook)
export const updateSubscription = privateMutation
  .input(z.object({ subscription: subscriptionSchema }))
  .output(z.object({ updated: z.boolean() }))
  .mutation(async ({ ctx, input: args }) => {
    const existing = await ctx
      .table('subscriptions')
      .get('subscriptionId', args.subscription.subscriptionId);

    if (!existing) {
      return { updated: false };
    }

    await existing.patch(args.subscription);
    return { updated: true };
  });

// Get active subscription for user
export const getActiveSubscription = privateQuery
  .input(z.object({ userId: zid('user') }))
  .output(z.object({ subscriptionId: z.string() }).nullable())
  .query(async ({ ctx, input: args }) => {
    const subscription = await ctx
      .table('subscriptions')
      .filter((q) => q.eq(q.field('userId'), args.userId))
      .filter((q) => q.eq(q.field('status'), 'active'))
      .first();

    if (!subscription) return null;

    return { subscriptionId: subscription.subscriptionId };
  });

// Cancel subscription (user action)
export const cancelSubscription = authAction
  .output(z.object({ success: z.boolean() }))
  .action(async ({ ctx }) => {
    const polar = getPolarClient();

    const subscription = await ctx.runQuery(
      internal.polarSubscription.getActiveSubscription,
      { userId: ctx.userId! }
    );

    if (!subscription) {
      throw new CRPCError({
        code: 'PRECONDITION_FAILED',
        message: 'No active subscription found',
      });
    }

    await polar.subscriptions.update({
      id: subscription.subscriptionId,
      subscriptionUpdate: { cancelAtPeriodEnd: true },
    });

    return { success: true };
  });

// Resume subscription (user action)
export const resumeSubscription = authAction
  .output(z.object({ success: z.boolean() }))
  .action(async ({ ctx }) => {
    const polar = getPolarClient();

    const subscription = await ctx.runQuery(
      internal.polarSubscription.getActiveSubscription,
      { userId: ctx.userId! }
    );

    if (!subscription) {
      throw new CRPCError({
        code: 'PRECONDITION_FAILED',
        message: 'No active subscription found',
      });
    }

    await polar.subscriptions.update({
      id: subscription.subscriptionId,
      subscriptionUpdate: { cancelAtPeriodEnd: false },
    });

    return { success: true };
  });

Customer Deletion Sync

Sync user deletion with Polar:

convex/functions/auth.ts
const createAuthOptions = (ctx: GenericCtx) =>
  ({
    user: {
      deleteUser: {
        enabled: true,
        afterDelete: async (user) => {
          const polar = getPolarClient();
          await polar.customers.deleteExternal({
            externalId: user.id,
          });
        },
      },
    },
  }) satisfies BetterAuthOptions;

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/webhook

Common Patterns

Check Organization Subscription

const subscription = await ctx
  .table('subscriptions', 'organizationId_status', (q) =>
    q.eq('organizationId', organizationId).eq('status', 'active')
  )
  .first();

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

Check User Subscription

const subscription = await ctx
  .table('subscriptions')
  .filter((q) => q.eq(q.field('userId'), userId))
  .filter((q) => q.eq(q.field('status'), 'active'))
  .first();

const isPremium = !!subscription;

API Reference

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