How I Integrated Polar.sh Payments Into My SaaS With Better Auth

May 8, 2026

Wiring Polar.sh Payments to an Organization, Not a User - What the Docs Don't Tell You

When I added subscription billing to timeli.sh, I chose Polar.sh as the payment layer and better-auth as the auth framework. On paper, both are excellent. In practice, there was one fundamental mismatch between how Polar + better-auth expect things to work and how timeli.sh is actually structured - and I spent more time fighting it than building the feature.
This post is the write-up I wish had existed before I started.



The problem: every guide assumes subscriptions belong to a user

Timeli.sh is a multi-tenant SaaS. A user creates an organization, and that organization is what subscribes. Multiple users can belong to an organization, so it would be wrong for the subscription to live on any individual user. The organization either has an active subscription or it doesn't - regardless of who's logged in.
Every better-auth example, every Polar integration guide, and every community snippet I found wires the subscription directly to the userId from the session. Something like:
// What the docs tell you to do
const { userId } = await auth.getSession(req);
await polar.subscriptions.create({ customerId: userId, ... });
That works fine if your unit of billing is a person. It completely falls apart if your unit of billing is an organization.



How Polar models customers

Polar tracks payments through a customer entity that it manages internally. That customer gets created on Polar's side when someone first checks out, and Polar stores its own customerId for them.
The standard better-auth Polar plugin creates one Polar customer per user, storing the polarCustomerId on the user record. I needed to create one Polar customer per organization and store the polarCustomerId on the organization record.



The setup: key files

The billing work landed in PR #26. Here's a map of the relevant pieces:
apps/admin/src/
├── config/
│ └── polar-billing.ts # Polar SDK init + plan config
├── lib/billing/
│ ├── ensure-billing-org.ts # Create/fetch Polar customer for an org
│ ├── persist-polar-subscription.ts # Write subscription data back to our DB
│ ├── subscription-access.ts # Read active subscription from org record
│ ├── install-billing-access.ts # Gate app installs behind subscription check
│ ├── polar-order-paid-sms.ts # Handle SMS top-up purchases
│ └── emit-subscription-status-event.ts
├── proxy/
│ └── with-polar-webhooks.ts # Webhook handler middleware
└── app/
├── billing/portal/
│ └── route.ts # Redirect to Polar billing portal
└── checkout/
└── page.tsx # Checkout UI



Step 1: Initialize Polar and define your plans

// apps/admin/src/config/polar-billing.ts
import { Polar } from "@polar-sh/sdk";

export const polar = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN!,
server: process.env.POLAR_SERVER as "sandbox" | "production" ?? "sandbox",
});

export const POLAR_PLANS = {
pro: {
productId: process.env.POLAR_PRO_PRODUCT_ID!,
name: "Pro",
},
} as const;
Nothing unusual here. The key decision is that this file has no concept of users - it only knows about Polar products.



Step 2: Ensure a Polar customer exists for the organization

This is where I diverge from every example you'll find. Instead of resolving a customer from the session user, I resolve it from the organization:
// apps/admin/src/lib/billing/ensure-billing-org.ts
import { polar } from "@/config/polar-billing";
import { db } from "@/lib/db";

export async function ensureBillingOrg(organizationId: string) {
const org = await db.organizations.findById(organizationId);

// If the org already has a Polar customer, return it
if (org.polarCustomerId) {
return org.polarCustomerId;
}

// Otherwise create a new Polar customer tied to the org
const customer = await polar.customers.create({
email: org.email,
name: org.name,
metadata: {
organizationId, // critical: stash this for webhook lookups later
},
});

// Persist back to the org record, not the user record
await db.organizations.update(organizationId, {
polarCustomerId: customer.id,
});

return customer.id;
}
The metadata.organizationId line is critical. When a Polar webhook fires later, this is how you trace the event back to your organization without any user context.



Step 3: The checkout flow

The checkout page gets the organization from the current session (better-auth gives me session.user.activeOrganizationId), then hands off to Polar using the organization's customer ID:
// apps/admin/src/app/checkout/page.tsx (simplified)
import { auth } from "@/auth";
import { ensureBillingOrg } from "@/lib/billing/ensure-billing-org";
import { polar, POLAR_PLANS } from "@/config/polar-billing";

export default async function CheckoutPage() {
const session = await auth.getSession();
const organizationId = session.user.activeOrganizationId;

const polarCustomerId = await ensureBillingOrg(organizationId);

const checkoutUrl = await polar.checkouts.create({
customerId: polarCustomerId,
productId: POLAR_PLANS.pro.productId,
successUrl: `${process.env.ADMIN_URL}/dashboard?checkout=success`,
});

redirect(checkoutUrl.url);
}
The user goes through Polar's hosted checkout. No subscription state is written here - that happens via webhook.



Step 4: Handle webhooks correctly

This is where the organization binding pays off. The webhook carries the customer metadata I stored at customer-creation time, so I can look up the right organization without touching the user at all:
// apps/admin/src/proxy/with-polar-webhooks.ts
import { polar } from "@/config/polar-billing";
import { persistPolarSubscription } from "@/lib/billing/persist-polar-subscription";

export async function withPolarWebhooks(req: Request) {
const event = await polar.webhooks.constructEvent(
await req.text(),
req.headers.get("polar-signature")!,
process.env.POLAR_WEBHOOK_SECRET!
);

if (
event.type === "subscription.created" ||
event.type === "subscription.updated" ||
event.type === "subscription.canceled"
) {
const organizationId =
event.data.customer.metadata?.organizationId as string;

if (!organizationId) {
console.error("Polar webhook missing organizationId in customer metadata");
return new Response("missing org", { status: 400 });
}

await persistPolarSubscription(organizationId, event.data);
}

return new Response("ok");
}



Step 5: Persist subscription state to the organization

// apps/admin/src/lib/billing/persist-polar-subscription.ts
import { db } from "@/lib/db";
import { emitSubscriptionStatusEvent } from "./emit-subscription-status-event";

export async function persistPolarSubscription(
organizationId: string,
subscription: PolarSubscription
) {
const status = subscription.status;

await db.organizations.update(organizationId, {
subscription: {
polarSubscriptionId: subscription.id,
status,
currentPeriodEnd: subscription.currentPeriodEnd,
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
productId: subscription.productId,
},
});

await emitSubscriptionStatusEvent(organizationId, status);
}
The organization record now owns the full subscription state. Nothing on the user record changes.



Step 6: Check subscription access

Anywhere in the admin app where I need to gate a feature behind an active subscription, I read from the org, not the user:
// apps/admin/src/lib/billing/subscription-access.ts
import { db } from "@/lib/db";

const ACTIVE_STATUSES = new Set(["active", "trialing"]);

export async function getSubscriptionAccess(organizationId: string) {
const org = await db.organizations.findById(organizationId);
const status = org.subscription?.status;

return {
hasAccess: ACTIVE_STATUSES.has(status ?? ""),
status: status ?? "none",
isPastDue: status === "past_due",
isCanceled: status === "canceled",
};
}
And the corresponding check that protects booking endpoints on the public-facing app:
// apps/web/src/utils/subscription-access.ts
export async function requireActiveSubscription(organizationId: string) {
const { hasAccess, isPastDue } = await getSubscriptionAccess(organizationId);

if (isPastDue) {
return { blocked: true, reason: "past_due" };
}

if (!hasAccess) {
return { blocked: true, reason: "inactive" };
}

return { blocked: false };
}
The public-facing booking app (apps/web) uses this to show a branded fallback page for inactive organizations instead of a booking form.



Step 7: Billing portal redirect

Polar provides a hosted billing portal where users can update payment methods, cancel, and so on. Since the customer is tied to the org, I redirect to it using the org's polarCustomerId:
// apps/admin/src/app/api/billing/portal/route.ts
import { auth } from "@/auth";
import { polar } from "@/config/polar-billing";
import { db } from "@/lib/db";

export async function GET(req: Request) {
const session = await auth.getSession(req);
const org = await db.organizations.findById(session.user.activeOrganizationId);

if (!org.polarCustomerId) {
redirect("/dashboard/settings/brand#billing");
}

const portalSession = await polar.customerSessions.create({
customerId: org.polarCustomerId,
});

redirect(portalSession.customerPortalUrl);
}



SMS credits: why I store them locally instead of querying Polar

This is the part that took the most design thought, and where I made the biggest departure from what you'd naturally reach for.
The Pro plan includes 100 SMS credits per billing cycle. Organizations can also purchase top-up bundles when they need more. On the surface, that sounds like something Polar should just... know. I'm already using Polar for payments, so why not query Polar's API whenever I need to know the remaining balance?
Three reasons made that impractical.
The SMS send path is on the hot path. Appointment reminders often fire in batches. An org with 20 appointments starting in the next hour will trigger 20 notifications in quick succession from the job processor. If every send required a round-trip to Polar's API to check the remaining balance, that's 20 sequential external HTTP calls during the critical notification window, with all the latency and rate-limit risk that brings. A local counter read from MongoDB is effectively free by comparison.
Polar has no concept of consumable credits. It knows about orders and subscriptions, but there is no API call that returns "this organization purchased 250 credits and has 137 remaining." To reconstruct that number from Polar alone, I'd have to fetch all historical order.paid events for the organization and subtract all the sends. Except Polar knows nothing about the sends - those go through TextBelt. The number of messages actually dispatched only exists in my own system. There's no way to close that loop without keeping a local counter.
The monthly reset needs to be event-driven, not lazy. The included 100 credits reset at the start of each new billing cycle. That reset should happen when the cycle actually rolls over - triggered by a webhook - not lazily when someone happens to query the balance. If I reconstructed the balance by summing Polar orders, I'd also have to reconstruct when the current cycle started, compare order timestamps against that boundary, and filter accordingly. That's fragile date arithmetic when I can just listen for subscription.updated and flip a field.
So the source of truth for credit balance lives on the organization record in MongoDB:
// simplified org schema shape
{
_id: ObjectId,
name: string,
polarCustomerId: string,
subscription: {
status: "active" | "trialing" | "past_due" | "canceled",
currentPeriodEnd: Date,
productId: string,
},
sms: {
creditsBalance: number, // current spendable balance
includedCreditsPerCycle: number, // credits the plan grants each reset (100 for Pro)
lastResetAt: Date, // when the balance was last topped up from the plan
}
}

Adding credits when a top-up is purchased

When someone buys a top-up bundle, Polar fires an order.paid webhook. The flow is identical to the subscription webhook - I resolve the organization from customer.metadata.organizationId and increment the balance atomically:
// apps/admin/src/lib/billing/polar-order-paid-sms.ts
export async function handleSmsTopUpPaid(
organizationId: string,
order: PolarOrder
) {
// The credit amount lives in the Polar product's metadata, not in code.
// This lets me offer different bundle sizes (50, 200, 500 credits) as
// separate Polar products without changing the application at all.
const creditAmount = order.product.metadata?.smsCredits as number;

if (!creditAmount || creditAmount <= 0) {
console.error("SMS top-up order missing smsCredits metadata", order.id);
return;
}

await db.organizations.findOneAndUpdate(
{ _id: organizationId },
{ $inc: { "sms.creditsBalance": creditAmount } }
);
}
Storing the credit amount in Polar product metadata rather than hardcoding it in the app means I can define bundle sizes entirely inside Polar's dashboard. To add a new tier I create a product, set smsCredits: 500 in its metadata, and the webhook handler picks it up automatically without a deploy.

Consuming credits when a message is sent

Before the SMS app dispatches a message through TextBelt, it checks and atomically decrements the balance. I use a findOneAndUpdate with a filter on the current balance rather than a read-then-write. This prevents race conditions when the job processor fires multiple notifications concurrently for the same organization - two concurrent jobs should never both succeed when only one credit remains:
// packages/app-store/src/apps/text-message-notification/service.ts (simplified)
export async function consumeSmsCredit(organizationId: string): Promise<boolean> {
const result = await db.organizations.findOneAndUpdate(
{
_id: organizationId,
"sms.creditsBalance": { $gt: 0 },
},
{
$inc: { "sms.creditsBalance": -1 },
},
{ returnDocument: "after" }
);

// If the filter matched nothing, the org had zero credits
return result !== null;
}
If consumeSmsCredit returns false, the notification service skips the send and records that the message was dropped due to insufficient credits. The appointment itself is not affected - only the SMS notification is skipped. The atomic filter-and-decrement guarantees that the balance never goes negative, even under concurrency.

Resetting the monthly included credits

Every Pro plan includes 100 SMS credits per billing cycle. I reset the balance by listening to the subscription.updated webhook that Polar sends whenever a billing period rolls over. The event carries a new currentPeriodEnd date, which I compare against what is stored on the org to detect whether a cycle boundary was crossed:
// apps/admin/src/lib/billing/persist-polar-subscription.ts (extended)
export async function persistPolarSubscription(
organizationId: string,
subscription: PolarSubscription
) {
const org = await db.organizations.findById(organizationId);
const previousPeriodEnd = org.subscription?.currentPeriodEnd;
const newPeriodEnd = subscription.currentPeriodEnd;

const isNewBillingCycle =
previousPeriodEnd &&
newPeriodEnd &&
new Date(newPeriodEnd) > new Date(previousPeriodEnd);

const update: Partial<Organization> = {
"subscription.status": subscription.status,
"subscription.currentPeriodEnd": newPeriodEnd,
"subscription.cancelAtPeriodEnd": subscription.cancelAtPeriodEnd,
"subscription.productId": subscription.productId,
};

if (isNewBillingCycle) {
// Carry-over top-up credits are never wiped. The reset only brings the
// balance up to the included amount if it is currently below it.
// If an org has 150 credits left from a top-up they barely used, they
// keep 150. If they have 30 left from the included allocation, they get
// bumped back up to 100.
const currentBalance = org.sms?.creditsBalance ?? 0;
const included = org.sms?.includedCreditsPerCycle ?? 100;

update["sms.creditsBalance"] = Math.max(currentBalance, included);
update["sms.lastResetAt"] = new Date();
}

await db.organizations.updateOne(
{ _id: organizationId },
{ $set: update }
);

await emitSubscriptionStatusEvent(organizationId, subscription.status);
}
The Math.max(currentBalance, included) line is the core policy decision. It expresses two things at once:
If an org is running low (below 100), the cycle reset tops them back up to 100. This is the expected behavior for someone who sends reminders regularly and relies on the included credits.
If an org has more than 100 credits because they bought top-ups, those credits are untouched. A 500-credit purchase does not get partially wiped by the monthly reset.
Purchased top-ups carry over indefinitely until spent. Included credits top up to their cap at each cycle boundary and do not accumulate beyond that.

Seeding the initial balance on subscription activation

When a subscription is first created or transitions to active from a trial, I need to seed the initial credit balance. The isNewBillingCycle check in the function above won't fire on the very first event because there is no previousPeriodEnd to compare against. I handle the initial seed as a separate case:
const isActivating =
!org.subscription?.status &&
["active", "trialing"].includes(subscription.status);

if (isActivating) {
update["sms.creditsBalance"] = org.sms?.includedCreditsPerCycle ?? 100;
update["sms.lastResetAt"] = new Date();
}
This runs only once, on first activation, and gives the org their first allotment of credits immediately.

The full credit lifecycle

Subscription activated (subscription.created, status = active or trialing)
-> sms.creditsBalance set to includedCreditsPerCycle (100)
-> sms.lastResetAt = now

Billing cycle renews (subscription.updated, newPeriodEnd > previousPeriodEnd)
-> creditsBalance = max(currentBalance, includedCreditsPerCycle)
-> lastResetAt = now

Organization sends SMS (job processor fires notification)
-> atomic findOneAndUpdate with filter { creditsBalance: { $gt: 0 } }
-> filter matched: creditsBalance -= 1, message is sent
-> filter did not match: message dropped, appointment unaffected

Organization purchases top-up bundle (order.paid webhook)
-> creditAmount read from order.product.metadata.smsCredits
-> creditsBalance += creditAmount (atomic $inc)
Keeping all of this in the database means the job processor never touches Polar during the critical notification path. The only time Polar is involved is when money changes hands - the webhook fires, the balance is updated, and from that point on everything runs locally.



The subscription status lifecycle

Here's how statuses flow and what timeli.sh does with each:
Polar status
What it means
Timeli.sh behavior
trialing
7-day free trial
Full access
active
Paying customer
Full access
past_due
Payment failed, retrying
Booking and payment actions blocked; portal CTA shown
canceled
Subscription ended
Org treated as inactive; public site shows fallback page
unpaid
Polar gave up collecting
Same as canceled
The past_due case is the important one to get right - cutting off access immediately on a failed payment is the wrong call, but blocking new bookings until the customer resolves their billing is correct.



What better-auth gives you (and doesn't)

better-auth handles the session, organization context, and role-based access cleanly. The session.user.activeOrganizationId is always available in server actions and API routes, which makes the org-scoped billing lookups straightforward.
What it doesn't give you out of the box is any Polar-specific integration that understands organizations. The official @better-auth/polar plugin routes everything through the user. If you're building user-level billing, use it. If you need org-level billing, you're on your own - which is fine, because wiring it yourself isn't that many lines of code. The complexity is in understanding the pattern, not implementing it.
The short version: create the Polar customer on the organization record, stash your organizationId in Polar's customer metadata, and from that point on every webhook and every access check operates off the org, never the user.



The full PR

Everything described in this post landed in pr #26 of the timeli.sh repository. The billing-specific files are under apps/admin/src/lib/billing/ and apps/admin/src/proxy/with-polar-webhooks.ts.
If you're building a similar multi-tenant SaaS with Polar and better-auth and ran into the same wall, hopefully this saves you a few hours.

blogposttimeli.shpolar.shsubscription

Contact me

Email

dmytro@bondarchuk.me
© 2026 Dmytro BondarchukCreated usingTimeli.sh