Announcing CIMD support for MCP Client registration
Learn more
B2B Authentication
Jul 27, 2026

Build B2B billing with Scalekit and Chargebee

Saif Ali Shaik
Founding Developer Advocate

TL;DR

  • A working app wires Scalekit to Chargebee organization billing: Scalekit provides the authenticated organization context, Scalekit webhooks create Chargebee customers, Chargebee hosted checkout creates subscriptions, and Chargebee webhooks sync subscription rows back into SQLite.
  • Scalekit OID becomes the billing reference for each organization.
  • organization.created provisions a Chargebee customer for the org.
  • /api/subscription/create creates a local future subscription row and opens hosted checkout.
  • Chargebee subscription webhooks update local subscription and subscription_item tables.
  • Checkout the source code on Github

Business-to-business billing gets messy when authentication, organizations, customers, checkout, and subscription state live in separate systems. This post shows a practical Next.js example app that uses Scalekit for organization-mode auth and Chargebee for subscription billing, with webhook sync keeping local billing state current.

Prerequisites

  • Node.js 18+
  • A Scalekit environment with organization support, so access tokens include oid
  • A Chargebee sandbox site using Product Catalog 2.0
  • A Chargebee test payment gateway for hosted checkout
  • LocalTunnel, ngrok, or another public tunnel for local webhook testing

How it fits together

Scalekit x Chargebee

Scalekit owns identity and organization context. Chargebee owns checkout, subscription lifecycle, and billing portal flows. Your app owns the local mapping between the Scalekit organization and Chargebee customer/subscription state.

Scalekit x Chargebee Architecture Flow Diagram

The important design choice is that the app uses the Scalekit organization ID as the billing reference. Billing route handlers call requireSession(), validate the Scalekit access token, read the oid claim, and use that organization ID when creating checkout sessions or listing subscriptions.

Clone and run the app

The app is a Next.js 14 project with the App Router, TypeScript, the Scalekit Node SDK, Chargebee SDK v3, Drizzle, and SQLite.

cp .env.example .env npm install npm run db:push npm run dev

Open http://localhost:3000, sign in with Scalekit, then open /guide for the local integration walkthrough.

Start with these environment variables before running the full billing flow:

SCALEKIT_ENV_URL=https://your-env.scalekit.dev SCALEKIT_CLIENT_ID=skc_... SCALEKIT_CLIENT_SECRET=... SCALEKIT_REDIRECT_URI=http://localhost:3000/auth/callback SCALEKIT_POST_LOGOUT_REDIRECT_URI=http://localhost:3000 NEXT_PUBLIC_APP_URL=http://localhost:3000 SCALEKIT_SCOPES=openid profile email offline_access SCALEKIT_WEBHOOK_SECRET=... CHARGEBEE_SITE=your-site-test NEXT_PUBLIC_CHARGEBEE_SITE=your-site-test CHARGEBEE_API_KEY=... CHARGEBEE_PUBLISHABLE_KEY=test_cu... NEXT_PUBLIC_CHARGEBEE_PUBLISHABLE_KEY=test_cu... CHARGEBEE_PLAN_ITEM_PRICE_ID=growth-plan-monthly CHARGEBEE_GATEWAY_ACCOUNT_ID=gw_your_test_gateway_id CHARGEBEE_WEBHOOK_USERNAME=... CHARGEBEE_WEBHOOK_PASSWORD=... DATABASE_URL=file:./data/billing.db

Use a tunnel URL for both webhook endpoints during local development:

Source
Endpoint
Scalekit
https:///api/webhooks/scalekit
Chargebee
https:///api/webhooks/chargebee

Model billing around the organization

The local database has three billing tables. This excerpt shows the organization and subscription shape:

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 organization ID. organization.chargebeeCustomerId stores the linked Chargebee customer. subscription.referenceId stores the organization ID used by checkout, list, portal, and cancel routes.

The reference app starts with one Growth plan. The plan item price comes from CHARGEBEE_PLAN_ITEM_PRICE_ID, has a 25-seat limit, and includes a 14-day trial.

Authenticate with Scalekit

Login starts at /api/auth/login. The route creates an OAuth state value, stores it for cross-site request forgery (CSRF) protection, and returns the Scalekit authorization URL. The excerpt below shows the core call:

const state = crypto.randomBytes(32).toString('base64url'); await setOAuthState(state); const authUrl = client.getAuthorizationUrl(redirectUri, { state, scopes: getDefaultScopes(), });

The callback route validates the state, exchanges the code, validates the access token, and stores a single HttpOnly scalekit_session cookie.

Billing routes do not trust the cookie contents alone. They call validateToken() and require oid before any billing action. The excerpt below shows the guard:

const claims = await client.validateToken(accessToken); const organizationId = claims.oid; if (!organizationId) { throw new SessionError(403, 'Organization context required for billing'); }

That check keeps billing tied to a Scalekit organization, not a user-supplied referenceId. This is the same principle behind API authentication in B2B SaaS — every action must be traceable to a verified identity and organization context.

Create Chargebee customers from Scalekit organizations

The Scalekit webhook route reads the raw body and verifies the signature with verifyWebhookPayload. After verification, it dispatches the event asynchronously and returns 200. The excerpt below shows the raw-body verification:

const rawBody = await req.text(); const isValid = client.verifyWebhookPayload( secret, headersToRecord(req), rawBody );

The dispatcher handles organization lifecycle events:

  • organization.created creates or reuses a local organization row, then creates a Chargebee customer.
  • organization.updated updates the local organization display name.
  • organization.deleted cleans up local billing state.

Customer creation stores the Scalekit organization ID in Chargebee metadata. The excerpt below shows the Chargebee customer payload:

const { customer } = await chargebee.customer.create({ company: displayName ?? undefined, email: email ?? undefined, preferred_currency_code: 'USD', meta_data: { organizationId, customerType: 'organization', }, });

That metadata gives webhook handlers another way to resolve Chargebee events back to the right organization. For a deeper look at how organization-scoped service accounts work as a billing reference pattern, the same principle of anchoring every external resource to the org ID applies.

Open hosted checkout

The billing UI calls POST /api/subscription/create with an item price ID. The route:

  • Requires a Scalekit session with oid.
  • Defaults referenceId to the authenticated organization ID.
  • Calls authorizeReference() so apps can deny cross-org billing actions.
  • Gets or creates the Chargebee customer for that organization.
  • Creates or reuses a local future subscription row.
  • Stores pending subscription metadata on the Chargebee customer.
  • Opens Chargebee hosted checkout.
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 }); }

The local future row is useful because hosted checkout and webhooks are asynchronous. The app has a local subscription ID before Chargebee sends the final subscription lifecycle event. The excerpt below shows the local row creation:

localSub = await createFutureSubscription({ referenceId, chargebeeCustomerId: customerId, });

Hosted checkout returns a URL. The excerpt below shows the hosted-page call:

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), });

When checkout succeeds, Chargebee redirects to /api/subscription/success. The route attempts to sync the latest Chargebee subscription into the local row, then redirects back to /billing?success=1.

Sync subscription lifecycle events

Chargebee webhooks are the source of truth for subscription state. The webhook route parses the raw event payload and dispatches supported lifecycle events:

  • subscription_created
  • subscription_activated
  • subscription_started
  • subscription_changed
  • subscription_renewed
  • subscription_scheduled_cancellation_removed
  • subscription_cancelled
  • Customer_deleted

The lifecycle handler resolves the local subscription in a few ways:

  • Existing chargebee_subscription_id on the local row.
  • subscription.meta_data.subscriptionId.
  • customer.meta_data.pendingSubscriptionId.
  • The future row for the organization reference.

After resolution, the app copies Chargebee state into local tables. The excerpt below shows the mapped fields:

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 the local subscription_item rows with the current Chargebee subscription items. That gives the billing UI a clean local read path. Understanding user lifecycle management is critical here — subscription events mirror the same offboarding and state-change patterns that govern user access.

Build the billing UI around local state

The /billing page loads the current session from /api/session, then loads active or trialing subscriptions from /api/subscription/list.

If the Scalekit token has no oid, the UI shows an organization-required state. If the organization has no active subscription, the UI shows the Growth plan and calls hosted checkout. If a subscription exists, the UI shows the current subscription and opens Chargebee portal flows for management or cancellation.

The middleware only checks whether the scalekit_session cookie exists for protected pages such as /dashboard and /billing. Real authorization happens inside route handlers through requireSession() and authorizeReference().

Customize lifecycle hooks

The app keeps application-specific behavior in lib/subscription-hooks.ts. Replace the demo console.log hooks with your own logic:

  • onCustomerCreate
  • onSubscriptionCreated
  • onSubscriptionComplete
  • onSubscriptionUpdated
  • onSubscriptionDeleted
  • onSubscriptionCancel
  • onTrialStart
  • onTrialEnd
  • onAuthorizeReference

For example, a production app might grant features after onSubscriptionComplete, send analytics events after onTrialStart, or deny billing actions from onAuthorizeReference unless the user has an admin role. Access control for multi-tenant applications follows the same pattern — each action must be evaluated against the user's role and organization membership before execution.

API routes to fork

Route
Purpose
GET /api/session
Return the authenticated user, organization ID, and available plans
POST /api/subscription/create
Create a local future subscription and open Chargebee checkout
GET /api/subscription/success
Sync the checkout result and redirect back to billing
GET /api/subscription/list
Return active or trialing subscriptions for the organization
POST /api/subscription/portal
Open the Chargebee customer portal
POST /api/subscription/cancel
Open the cancellation flow
POST /api/webhooks/scalekit
Verify Scalekit webhooks and sync organization lifecycle
POST /api/webhooks/chargebee
Process Chargebee billing lifecycle events

Run the 5-minute test

  1. Create an organization in Scalekit, or trigger the organization.created webhook.
  2. Confirm the local organization row exists and has a Chargebee customer ID.
  3. Log in at /login with a user in that organization.
  4. Open /billing and subscribe to the Growth plan.
  5. Complete Chargebee hosted checkout with a sandbox card.
  6. Confirm the redirect to /billing?success=1.
  7. Wait for Chargebee webhooks, then refresh /billing.
  8. Confirm the active or trialing subscription appears.

Production notes

The reference app is intentionally small, so the important boundaries are visible. Before using the pattern in production, tighten these areas:

  • Configure CHARGEBEE_WEBHOOK_USERNAME and CHARGEBEE_WEBHOOK_PASSWORD. The current route logs a warning and skips Basic Auth when those variables are missing.
  • Keep SCALEKIT_WEBHOOK_SECRET required. The Scalekit webhook route returns an error if the secret is missing.
  • Replace the demo lifecycle hooks with feature gating, analytics, customer success, or entitlement logic.
  • Add database migrations. The sample uses Drizzle db:push and does not include migration files.
  • Add automated tests for auth, checkout, webhook replay, and authorization failures.
  • Avoid exposing raw tokens in production debugging pages. The sample includes developer-oriented session inspection.

Why this pattern works

Scalekit and Chargebee each stay responsible for the system they are best suited to own. Scalekit validates the user identity and organization membership. Chargebee manages customer billing, checkout, subscription state, and portal flows. Your app ties the two together with an organization reference and a small local subscription model.

That split keeps the most important invariant clear: every billing action starts from the authenticated Scalekit organization, not from a client-supplied customer or subscription ID. This is the same architectural boundary described in when to build authentication into your B2B SaaS — identity decisions made early prevent expensive re-architecture later.

Explore more

  • Quickstart adding auth to your SaaS and extend billing out of the box.
  • Extend lib/subscription-hooks.ts with the product behavior your app needs after subscription events.
  • Use the same organization-reference pattern when you add more plans, portal flows, or entitlements.
  • See how audit trails for agent auth in B2B SaaS apply the same organization-anchored identity model to compliance and governance requirements.
  • Learn how M2M authentication readiness extends this pattern when automated agents — not human users — initiate billing actions.
No items found.
Agent Auth Quickstart
On this page
Share this article
Agent Auth Quickstart

Acquire enterprise customers with zero upfront cost

Every feature unlocked. No hidden fees.
Start Free
$0
/ month
1 million Monthly Active Users
100 Monthly Active Organizations
1 SSO connection
1 SCIM connection
10K Connected Accounts
Unlimited Dev & Prod environments