DEV Community

Cover image for Stop keying B2B subscriptions to users | Scalekit + Chargebee for Org-based billing
Saif
Saif

Posted on

Stop keying B2B subscriptions to users | Scalekit + Chargebee for Org-based billing

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 oid claim from the validated access token is the billing reference. Not a client-supplied ID.
  • organization.created → provision a Chargebee customer.
  • POST /api/subscription/create → local future row, then hosted checkout.
  • Chargebee lifecycle webhooks → sync into local subscription / subscription_item tables.
  • 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
Enter fullscreen mode Exit fullscreen mode

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'),
});
Enter fullscreen mode Exit fullscreen mode

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(),
});
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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',
  },
});
Enter fullscreen mode Exit fullscreen mode

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:

  1. Require a Scalekit session with oid
  2. Default referenceId to the authenticated org ID
  3. Call authorizeReference() so apps can deny cross-org actions
  4. Get or create the Chargebee customer
  5. Create or reuse a local future subscription row
  6. 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 });
}

Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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),
});
Enter fullscreen mode Exit fullscreen mode

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:

  1. Existing chargebee_subscription_id on the local row
  2. subscription.meta_data.subscriptionId
  3. customer.meta_data.pendingSubscriptionId
  4. The future row 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),
});
Enter fullscreen mode Exit fullscreen mode

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:

  • onCustomerCreate
  • onSubscriptionCreated
  • onSubscriptionComplete
  • onSubscriptionUpdated
  • onSubscriptionDeleted
  • onSubscriptionCancel
  • onTrialStart
  • onTrialEnd
  • onAuthorizeReference Grant features in onSubscriptionComplete. Fire analytics in onTrialStart. Deny non-admin billing actions in onAuthorizeReference. 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

  1. Create an org in Scalekit (or trigger organization.created)
  2. Confirm the local org row exists with a Chargebee customer ID
  3. Log in at /login as a user in that org
  4. Open /billing, subscribe to Growth
  5. Complete checkout with a sandbox card
  6. Confirm redirect to /billing?success=1
  7. Wait for webhooks, refresh
  8. 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_SECRET required. 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:push with 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.

Source on GitHub →

Top comments (1)

Collapse
 
mihirkanzariya profile image
Mihir kanzariya

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 oid claim. 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.