There's a moment in every B2B SaaS build where you have working auth, a Chargebee account, and a blinking cursor over the question: what do I actually key the subscription to?
If you key it to a user, you've just built a B2C product with extra steps. Invite a teammate and your billing model falls apart. Promote someone and the subscription belongs to the wrong person. Offboard the founder who signed up and you've orphaned the account.
The answer is the organization. This post walks through a working Next.js 14 app that makes the Scalekit organization ID the billing reference for Chargebee, end to end.
Source: scalekit-developers/saas-auth-chargebee-example
TL;DR
- Scalekit owns identity + org context. Chargebee owns checkout, subscription lifecycle, and portal flows. Your app owns a thin mapping table.
- The
oidclaim from the validated access token is the billing reference. Not a client-supplied ID. -
organization.created→ provision a Chargebee customer. -
POST /api/subscription/create→ localfuturerow, then hosted checkout. - Chargebee lifecycle webhooks → sync into local
subscription/subscription_itemtables. - The UI reads local state only. ## Stack
Next.js 14 (App Router), TypeScript, Scalekit Node SDK, Chargebee SDK v3, Drizzle, SQLite.
Prerequisites: Node 18+, a Scalekit environment with organization support (so tokens carry oid), a Chargebee sandbox on Product Catalog 2.0, a test payment gateway, and a tunnel (LocalTunnel/ngrok) for webhooks.
cp .env.example .env
npm install
npm run db:push
npm run dev
Then hit http://localhost:3000, sign in, and open /guide.
The schema is the whole argument
Three billing tables. Here are the two that matter:
export const organization = sqliteTable('organization', {
id: text('id').primaryKey(),
chargebeeCustomerId: text('chargebee_customer_id').unique(),
displayName: text('display_name'),
updatedAt: integer('updated_at', { mode: 'timestamp' }),
});
export const subscription = sqliteTable('subscription', {
id: text('id').primaryKey(),
referenceId: text('reference_id').notNull(),
chargebeeCustomerId: text('chargebee_customer_id'),
chargebeeSubscriptionId: text('chargebee_subscription_id').unique(),
status: text('status').notNull().default('future'),
seats: integer('seats'),
metadata: text('metadata'),
});
organization.id is the Scalekit org ID. Not a foreign key to it — it is it. subscription.referenceId holds the same value, and every billing route (checkout, list, portal, cancel) resolves through it.
The reference app ships one Growth plan: 25-seat limit, 14-day trial, item price from CHARGEBEE_PLAN_ITEM_PRICE_ID.
The guard that does the actual work
Login starts at /api/auth/login with standard OAuth state + CSRF handling:
const state = crypto.randomBytes(32).toString('base64url');
await setOAuthState(state);
const authUrl = client.getAuthorizationUrl(redirectUri, {
state,
scopes: getDefaultScopes(),
});
The callback validates state, exchanges the code, and stores a single HttpOnly scalekit_session cookie.
Now the important bit — billing routes do not trust the cookie contents:
const claims = await client.validateToken(accessToken);
const organizationId = claims.oid;
if (!organizationId) {
throw new SessionError(403, 'Organization context required for billing');
}
If you take one thing from this post, take this. The middleware only checks that the cookie exists for protected pages like /dashboard and /billing. Real authorization happens inside route handlers via requireSession() and authorizeReference(). A cookie-existence check is a routing concern, not a security boundary.
Provisioning customers from org events
The Scalekit webhook route reads the raw body and verifies the signature before parsing anything:
const rawBody = await req.text();
const isValid = client.verifyWebhookPayload(
secret,
headersToRecord(req),
rawBody
);
Raw body, then verify, then dispatch async and return 200. Parsing before verifying is how you end up processing forged events.
The dispatcher handles three org lifecycle events:
| Event | Behaviour |
|---|---|
organization.created |
Create/reuse local org row, create Chargebee customer |
organization.updated |
Update local display name |
organization.deleted |
Clean up local billing state |
Customer creation stamps the org ID into Chargebee metadata:
const { customer } = await chargebee.customer.create({
company: displayName ?? undefined,
email: email ?? undefined,
preferred_currency_code: 'USD',
meta_data: {
organizationId,
customerType: 'organization',
},
});
That metadata is your second resolution path when a webhook arrives and you need to map a Chargebee event back to an org.
Checkout, and the async gap nobody warns you about
POST /api/subscription/create does six things in order:
- Require a Scalekit session with
oid - Default
referenceIdto the authenticated org ID - Call
authorizeReference()so apps can deny cross-org actions - Get or create the Chargebee customer
- Create or reuse a local
futuresubscription row - Open hosted checkout Step 3 is the one people skip:
const referenceId = body.referenceId ?? ctx.organizationId;
const authorized = await authorizeReference({
userId: ctx.userId,
organizationId: ctx.organizationId,
referenceId,
action: 'create',
});
if (!authorized) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
Note the shape: referenceId can come from the body, but it's defaulted from the token and then explicitly authorized against the session context. That's the difference between a billing endpoint and an IDOR.
Step 5 solves a race you'll otherwise discover in production. Hosted checkout redirects the user back on its own schedule; Chargebee webhooks arrive on theirs. Sometimes the redirect wins. So you mint a local ID first:
localSub = await createFutureSubscription({
referenceId,
chargebeeCustomerId: customerId,
});
Then open the hosted page:
const result = await chargebee.hostedPage.checkoutNewForItems({
subscription_items: itemPriceIds.map((id) => ({
item_price_id: id,
quantity: seats,
})),
customer: { id: customerId },
redirect_url: successRedirect,
cancel_url: absoluteUrl(cancelUrl),
});
On success, Chargebee hits /api/subscription/success, which attempts an immediate sync and redirects to /billing?success=1. If the webhook beat it there, the sync is a no-op. Either order works.
Webhooks are the source of truth
Handled events:
subscription_created, subscription_activated, subscription_started, subscription_changed, subscription_renewed, subscription_scheduled_cancellation_removed, subscription_cancelled, customer_deleted
Resolution is layered, most-specific first:
- Existing
chargebee_subscription_idon the local row subscription.meta_data.subscriptionIdcustomer.meta_data.pendingSubscriptionId- The
futurerow for the org reference Four fallbacks looks like overkill until you've watched a webhook arrive for a subscription your app has never heard of.
Then copy state down:
const updated = await updateSubscription(local.id, {
referenceId: local.referenceId,
chargebeeCustomerId: cbSub.customer_id ?? local.chargebeeCustomerId,
chargebeeSubscriptionId: cbSub.id,
status: cbSub.status,
periodStart: unixToDate(cbSub.current_term_start),
periodEnd: unixToDate(cbSub.current_term_end),
trialStart: unixToDate(cbSub.trial_start),
trialEnd: unixToDate(cbSub.trial_end),
canceledAt: unixToDate(cbSub.cancelled_at),
seats: extractSeats(cbSub),
metadata: JSON.stringify(cbSub.meta_data ?? null),
});
The handler also replaces local subscription_item rows wholesale with current Chargebee items. Replace, don't merge — partial diffing subscription items is a bug generator.
Net effect: /billing never calls Chargebee on render. It reads /api/session and /api/subscription/list off local state. Fast page, no third-party latency in your critical path.
Where you plug in your product
lib/subscription-hooks.ts holds every application-specific behaviour, stubbed with console.log:
onCustomerCreateonSubscriptionCreatedonSubscriptionCompleteonSubscriptionUpdatedonSubscriptionDeletedonSubscriptionCancelonTrialStartonTrialEnd-
onAuthorizeReferenceGrant features inonSubscriptionComplete. Fire analytics inonTrialStart. Deny non-admin billing actions inonAuthorizeReference. That last one is where role checks belong — a member shouldn't be able to cancel the org's plan.
Routes to fork
| Route | Purpose |
|---|---|
GET /api/session |
Authenticated user, org ID, available plans |
POST /api/subscription/create |
Local future row + open checkout |
GET /api/subscription/success |
Sync checkout result, redirect |
GET /api/subscription/list |
Active/trialing subs for the org |
POST /api/subscription/portal |
Chargebee customer portal |
POST /api/subscription/cancel |
Cancellation flow |
POST /api/webhooks/scalekit |
Verify + sync org lifecycle |
POST /api/webhooks/chargebee |
Process billing lifecycle |
Five-minute smoke test
- Create an org in Scalekit (or trigger
organization.created) - Confirm the local org row exists with a Chargebee customer ID
- Log in at
/loginas a user in that org - Open
/billing, subscribe to Growth - Complete checkout with a sandbox card
- Confirm redirect to
/billing?success=1 - Wait for webhooks, refresh
- Confirm active/trialing subscription appears ## Before you ship this
It's a reference app. Deliberately small so the boundaries are visible. Tighten these:
-
Set
CHARGEBEE_WEBHOOK_USERNAME/CHARGEBEE_WEBHOOK_PASSWORD. The route currently logs a warning and skips Basic Auth when they're missing. Unauthenticated webhook endpoints are a real attack surface. -
Keep
SCALEKIT_WEBHOOK_SECRETrequired. That route already errors without it. Leave it that way. - Replace the demo hooks with actual entitlement logic.
-
Add migrations. The sample uses Drizzle
db:pushwith no migration files. Fine for a demo, not for a team. - Add tests for auth, checkout, webhook replay, and authorization failures. Especially replay.
- Remove the debug pages. The sample exposes session inspection for developer convenience. And SQLite is a demo choice. Swap in whatever you actually run.
Why the split holds up
Scalekit validates identity and org membership. Chargebee runs checkout, subscription state, and portal flows. Your app owns one org reference and a small local model.
The invariant that falls out: every billing action starts from an authenticated organization, not from a client-supplied customer or subscription ID. That single rule eliminates a whole class of cross-tenant bugs, and it's cheap to enforce if you decide it on day one.
Fork it, rip out the hooks, keep the boundary.
Top comments (1)
The line worth naming is that a cookie-existence check is a routing concern and the real authorization happens in the route handler against the validated
oidclaim. Plenty of Next.js billing code stops at the middleware and calls it done.The thing I'd ask about is seats. That integer now describes something that also lives in your membership data and in whatever quantity Chargebee is billing, and those drift the moment somebody joins through a path that isn't a billing path, which in a B2B app is most of them. Is adding a member gated on a successful quantity update at the provider, or does the invite succeed locally and reconcile afterwards? Both are defensible, but the second means serving seats nobody is billed for, and it tends to go unnoticed because nothing throws.
Mid-cycle seat changes also force a proration decision, which is a pricing call rather than a technical one, and it's easier to make deliberately before the first org hits 25 than after. Same question at trial end, since 14 days with no payment method has to resolve into some concrete state and the seats number has to agree with it.