Polar.sh + BetterAuth for Organizations

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) { r

Apr 15, 2025 - 17:12
 0
Polar.sh + BetterAuth for Organizations

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 && ( )} {/* OTHERWISE ALLOW USERS TO SWITCH THE ORGANISATION */}
); }

Let's move on

Remember when I said we must disable the BetterAuth plugin for checkout?
We need to have a custom check which will allow us to pass our custom orgID as Metadata.

If you noticed, we have a Checkout component that we are importing in the checkout page, let's create that next.

"use client";
import { Button } from "@/components/ui/button";
import { polarClient } from "@/lib/auth";
import { useRouter } from "next/navigation";
import React, { useState } from "react";
import { toast } from "react-toastify";

const Subscribe = ({
  product,
  products,
  org,
  email,
}: {
  product: string;
  products: string[];
  org: string;
  email: string;
}) => {
  //
  const [loading, setLoading] = useState(false);
  const router = useRouter();

  const handleSubscribe = async () => {
    try {
      setLoading(true);
      const result = await polarClient.checkouts.create({
        productId: product,
        products: products,
        productPriceId: "",
        customerEmail: email,
        // We are passing the ORG as metadata since it's required by our Webhook handler we created ealier
        metadata: {
          org: org,
        },
      });

      router.push(result.url);

      setLoading(false);
    } catch (error) {
      toast.error("Failed to create checkout session.");
      setLoading(false);
    }
  };

  return (
    <Button
      className="w-full mb-6"
      variant={isPopular ? "default" : "outline"}
      onClick={handleSubscribe}
      loading={loading}
    >
      Get Started
    Button>
  );
};

export default Subscribe;

Finally

You can get the subscription status from our database since we're saving that using the Webhooks, and use that to get full subscription details from Polar.

export async function getSubscription() {
  const { org } = await getSession();

  const subscription = await prisma.subscription.findUnique({
    where: {
      organisation_id: org,
    },
  });

  const sub = subscription?.subscription_id
    ? await polarClient.subscriptions.get({
        id: subscription.subscription_id!,
      })
    : await Promise.resolve(null);

  if (!sub || sub.status !== "active") redirect("/checkout");

  return sub;
}

BONUS

I also added feature management to restrict which features an organisation has based on their product id

export async function checkFeatureAccess(feature: Feature): Promise<boolean> {


  enum Feature {
    LiveChat = "livechat",
    AiTools = "ai tools",
    AiAnalysis = "ai analysis",
  }
  const POLAR_STARTER_PRICING = process.env.POLAR_STARTER_PRICING!;
  const POLAR_BUSINESS_PRICING = process.env.POLAR_BUSINESS_PRICING!;
  const POLAR_ENTERPRICE_PRICING = process.env.POLAR_ENTERPRICE_PRICING!;

 const featureAccessConfig: { [productId: string]: Feature[] } = {
  [POLAR_STARTER_PRICING]: [
    // ENUMS OF FEATURES
  ],
  [POLAR_BUSINESS_PRICING]: [
    // ENUMS OF FEATURES
  ],
  [POLAR_ENTERPRICE_PRICING]: [
  // ENUMS OF FEATURES
  ],
};

  const subscription = await getSubscription();
  if (!subscription) {
    return false; // or redirect, but getSubscription already redirects for no active sub
  }

  const allowedFeatures = featureAccessConfig[subscription.productId];

  if (!allowedFeatures) {
    return false; // Default deny if productId not in config
  }

  return allowedFeatures.includes(feature);
}

Enjoy, and remember to follow on X
X - https://x.com/amazing_sly

Code copied from: Venturate