Recently, I came across a challenge integrating Polar.sh with BetterAuth for organization-based subscription.

The Problem

Polar customer email is unique, and if the user manages multiple organizations, you can not have each organization as a customer on Polar since you would need a unique email address for each.

If you have a user who manages multiple organizations within your app, those organizations will have to share the same user customer email, and the BetterAuth plugin from Polar does not have a workaround as it stands.

The Solution

The best workaround I found as I was building my SaaS Idea Validation app Venturate.

Before we start

If you face any challenges and would like some assistance, DM me on X - https://x.com/amazing_sly

Make sure to have the BetterAuth plugin installed yarn add @polar-sh/better-auth and also install the Polar SDK yarn add @polar-sh/sdk

Better Auth + Polar + Orgs

import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { organization } from "better-auth/plugins";
import { nextCookies } from "better-auth/next-js";
import { prisma } from "@/prisma/prisma";
import { Polar } from "@polar-sh/sdk";
import { polar } from "@polar-sh/better-auth";

export const polarClient = new Polar({
  accessToken: process.env.POLAR_ACCESS_TOKEN!,
  server: "sandbox",
});

export const auth = betterAuth({
  database: prismaAdapter(prisma, {
    provider: "postgresql",
  }),
  plugins: [
    polar({
      client: polarClient,
      createCustomerOnSignUp: true, // IMPORTANT: Make sure this is enabled.
      enableCustomerPortal: false, // There is no need for this, we will create our own
      checkout: {
        enabled: false, // This is important since we're going to implement a custom checkout flow
        products: [], // Required for some reasons I dont understand, but leave it empty for now
      },
      /**
       * Webhook configuration for handling Polar subscription events.
       * @property {string} secret - The webhook secret from Polar for verifying webhook authenticity.
       * @property {Function} onPayload - Async handler for processing webhook events.
       * @param {Object} params - The webhook payload parameters
       * @param {Object} params.data - The subscription event data from Polar

       * @param {string} params.data.metadata.org - Organization ID from the metadata
       * @param {string} params.type - Type of subscription event. Can be one of:
       *   - 'subscription.created' - New subscription created
       *   - 'subscription.active' - Subscription became active
       *   - 'subscription.canceled' - Subscription was canceled
       *   - 'subscription.revoked' - Subscription access was revoked
       *   - 'subscription.uncanceled' - Subscription cancellation was reversed
       *   - 'subscription.updated' - Subscription details were updated
       * @throws {Error} Throws error if organization cannot be found
       */
      webhooks: {
        secret: process.env.POLAR_WEBHOOK_SECRET!, // We need to enable webhooks on Polar as well
        onPayload: async ({ data, type }) => {
          if (
            type === "subscription.created" ||
            type === "subscription.active" ||
            type === "subscription.canceled" ||
            type === "subscription.revoked" ||
            type === "subscription.uncanceled" ||
            type === "subscription.updated"
          ) {
            const org = await prisma.organization.findUnique({
              where: { id: data.metadata.org as string },
            });
            if (!org) throw new Error("Error, something happened");
            await prisma.subscription.upsert({
              create: {
                status: data.status,
                organisation_id: org?.id,
                subscription_id: data.id,
                product_id: data.productId,
              },
              update: {
                status: data.status,
                organisation_id: org?.id,
                subscription_id: data.id,
                product_id: data.productId,
              },
              where: {
                organisation_id: org.id,
              },
            });
          }
        },
      },
    }),
    organization(), // Make sure you have the Org plugin initialized as well
    nextCookies(), // We need this is using NextJS for better cookie handling
  ],
  //   ... REST OF YOUR CONFIG
});

IMPORTANT

  • Make sure you have enabled Webhooks on Polar
  • Set your Webhooks URL to APP_URL/api/auth/polar/webhooks
  • Allow subscription events on Webhooks

Let's Proceed

We need to create a custom getSession that will return our user details and current organization details as well

export async function getSession() {


  const headersList = await headers();
  const sessionData = await auth.api.getSession({ headers: headersList });

  if (!sessionData?.session) {
    redirect("/auth/sign-in");
  }

  const { session, user } = sessionData;

  const [member, activeOrg] = await Promise.all([
    auth.api.getActiveMember({ headers: headersList }),
    session.activeOrganizationId
      ? prisma.organization.findFirst({
          where: {
            id: session.activeOrganizationId,
          },
        })
      : Promise.resolve(null),
  ]);

  if (!session.activeOrganizationId || !activeOrg || !member) {
    redirect("/switch-org");
  }

  return {
    userId: user.id,
    org: session.activeOrganizationId!,
    email: user.email,
    name: user.name,
    image: user.image,
    role: member.role,
    orgName: activeOrg.name,
    orgDomain: activeOrg.domain,
  };
}

Moving on

Remember the customer portal we disabled from BetterAuth plugin?
We did that because we have to create a custom portal for each organization so anyone who is part of the organization/admins can manage subscriptions, not just the single user who created it.

Generates a customer portal URL for managing subscription settings.

This function creates a session URL that allows customers to access their subscription management portal through Polar. The portal enables users to view and modify their subscription settings, billing information, and more.

export async function generateCustomerURL() {
  // Get the current organization ID from the session
  const { org } = await getSession();

  // Look up the subscription record for this organization
  const subscription = await prisma.subscription.findFirst({
    where: {
      organisation_id: org,
    },
  });

  if (!subscription) return null;

  // Fetch the full subscription details from Polar
  const polarSubscription = await polarClient.subscriptions.get({
    id: subscription.subscription_id!, // Assert non-null since we found a subscription
  });

  if (!polarSubscription) return null;

  // Create a new customer portal session and get the access URL
  const portalSession = await polarClient.customerSessions.create({
    customerId: polarSubscription.customerId,
  });

  const url = portalSession.customerPortalUrl;

  return url;
}

Next up

Now we need to configure our custom checkout in our app.
Create a checkout page on app/checkout and add the following.

By default, we're fetching all products from Polar, hence we didn't specify any products when initializing the plugin.

import { polarClient } from "@/lib/auth";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import Subscribe from "./Subscribe";
import { getSession } from "@/actions/account/user";
import { generateCustomerURL } from "@/actions/account/subscription";

export default async function Page() {
  //
  const { org, email } = await getSession();

  const [{ result }, portalUrl] = await Promise.all([
    polarClient.products.list({
      isArchived: false,
    }),
    generateCustomerURL(),
  ]);

  const sortedProducts = [...result.items].sort((a, b) => {
    const priceA = a.prices[0]?.priceAmount || 0;
    const priceB = b.prices[0]?.priceAmount || 0;
    return priceA - priceB;
  });

  return (
    <div>
      <h1>Join Venturate</h1>
      <p>Choose the plan that's right for you

      
        {sortedProducts.map((product) => {
          const price =
            product.prices[0]?.amountType === "fixed"
              ? `$${product.prices[0].priceAmount / 100}/month`
              : product.prices[0]?.amountType === "free"
              ? "Free"
              : "Pay what you want";

          return (
            
              {product.name}
              {product.description}
              {price}

               p.id)}
                org={org}
                email={email}
              />
            
          );
        })}
      

      
        {/* IMPORTANT: if we have portal URL, that means we already have an existing subscription, so we can redirect to that. */}
        {portalUrl && (
          
            Manage Subscription
          
        )}

        {/* OTHERWISE ALLOW USERS TO SWITCH THE ORGANISATION */}
        
          Switch Organisation
); }
Enter fullscreen mode Exit fullscreen mode