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