Sync B2B billing with Scalekit and Chargebee
Map Scalekit organizations to Chargebee customers, run hosted checkout, and keep subscription state in sync via webhooks.
Multi-tenant B2B SaaS apps authenticate users through Scalekit organizations, but bill through Chargebee subscriptions. Those two systems do not share a database. Without an explicit mapping, you end up with duplicate Chargebee customers, subscriptions that never activate after checkout, or feature gates that read stale plan data.
This cookbook wires Scalekit Full Stack Auth to Chargebee using org-mode billing: the organization ID from the access token (oid) becomes the billing referenceId, Scalekit webhooks provision Chargebee customers, and Chargebee webhooks keep your local subscription table current.
The problem
Section titled “The problem”Shipping org-level billing on top of Scalekit auth surfaces four recurring failures:
- Orphan organizations — a customer signs up in Scalekit, but no Chargebee customer exists when they open your billing page.
- ID drift — you create Chargebee customers keyed by email or an internal UUID while auth sessions carry
oid. Checkout and webhooks cannot reconcile the two records. - Checkout without local state — hosted checkout succeeds, but your app still shows “no subscription” because nothing linked the Chargebee subscription back to the org.
- Webhook blind spots — Scalekit org events and Chargebee subscription events update different stores. Without handlers on both sides, deletes and plan changes leave ghost data behind.
Who needs this
Section titled “Who needs this”This cookbook is for you if:
- ✅ You run Full Stack Auth with organization support (
oidin access tokens) - ✅ You bill per organization, not per individual user
- ✅ You use Chargebee hosted checkout or the customer portal
- ✅ You maintain a local subscription cache to gate features in your app
You don’t need this if:
- ❌ You bill per user, not per organization
- ❌ You use the Chargebee Better Auth adapter and Better Auth’s session model
- ❌ Scalekit manages your entire product catalog and entitlements (no separate billing system)
The solution
Section titled “The solution”Treat the Scalekit organization ID as the single billing reference for the tenant. The integration has three seams:
- Provision on org create — Scalekit
organization.createdwebhook → create a Chargebee customer and store the mapping locally. - Future subscription before checkout — create a local row with
status: futurebefore redirecting to Chargebee hosted checkout. StamppendingSubscriptionIdon the Chargebee customer metadata so webhooks can match the right row. - Reconcile from Chargebee — Chargebee subscription webhooks (and an eager sync on checkout redirect) update the local row to
activeorin_trial.
Authorization stays in your app: every billing API call checks that referenceId === organizationId from the session before calling Chargebee.
Before you start
Section titled “Before you start”Gather these before you write code:
| Prerequisite | Where to get it |
|---|---|
| Scalekit environment with organizations | Scalekit dashboard |
OAuth client (skc_...) + redirect URI | API Keys in the dashboard |
| Chargebee sandbox site (Product Catalog 2.0) | Chargebee test site |
Plan item price ID (e.g. growth-plan-monthly) | Chargebee Product Catalog |
Test payment gateway (gw_...) | Chargebee Payment Gateways |
| Public tunnel for webhooks | ngrok, LocalTunnel, or similar |
Environment variables (store in .env, never commit):
SCALEKIT_ENV_URL=https://your-env.scalekit.devSCALEKIT_CLIENT_ID=skc_...SCALEKIT_CLIENT_SECRET=SCALEKIT_WEBHOOK_SECRET=CHARGEBEE_SITE=your-site-testCHARGEBEE_API_KEY=CHARGEBEE_PLAN_ITEM_PRICE_ID=growth-plan-monthlyCHARGEBEE_GATEWAY_ACCOUNT_ID=gw_your_test_gateway_idCHARGEBEE_WEBHOOK_USERNAME=CHARGEBEE_WEBHOOK_PASSWORD=NEXT_PUBLIC_APP_URL=http://localhost:3000Implementation
Section titled “Implementation”1. Store the org ↔ customer mapping
Section titled “1. Store the org ↔ customer mapping”Add tables for organizations and subscriptions. The organization row holds the Chargebee customer ID; subscriptions are keyed by reference_id (the Scalekit org ID).
CREATE TABLE organization ( id TEXT PRIMARY KEY, display_name TEXT, chargebee_customer_id TEXT UNIQUE);
CREATE TABLE subscription ( id TEXT PRIMARY KEY, reference_id TEXT NOT NULL, chargebee_customer_id TEXT NOT NULL, chargebee_subscription_id TEXT, status TEXT NOT NULL DEFAULT 'future', plan_id TEXT, seats INTEGER DEFAULT 1, trial_start INTEGER, trial_end INTEGER, current_period_end INTEGER, cancel_at_period_end INTEGER DEFAULT 0);Translate to your ORM. The reference_id column always stores the Scalekit organization ID from the oid claim.
2. Provision a Chargebee customer when Scalekit creates an org
Section titled “2. Provision a Chargebee customer when Scalekit creates an org”Register a Scalekit webhook for organization.created, organization.updated, and organization.deleted. Verify the signature on the raw request body before parsing JSON.
import { NextRequest, NextResponse } from 'next/server';import { getScalekitClient } from '@/lib/scalekit';import { createOrgCustomer } from '@/lib/billing/create-org-customer';
export async function POST(req: NextRequest) { const rawBody = await req.text(); const secret = process.env.SCALEKIT_WEBHOOK_SECRET!; const scalekit = getScalekitClient();
// Get the Scalekit signature header (do not use a full headers map for the new API) const signature = req.headers.get('scalekit-signature') ?? '';
// Verify webhook signature using the current SDK: scalekit.webhooks.verifySignature(rawBody, signature, secret) const isValid = await scalekit.webhooks.verifySignature(rawBody, signature, secret); if (!isValid) { return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }); }
const event = JSON.parse(rawBody);
if (event.type === 'organization.created') { const organizationId = event.organization_id ?? event.data?.id; await createOrgCustomer({ organizationId, displayName: event.data?.display_name ?? null, }); }
return NextResponse.json({ received: true });}The createOrgCustomer helper upserts the local organization row, creates a Chargebee customer if one does not exist, and stores organizationId in Chargebee meta_data:
const { customer } = await chargebee.customer.create({ company: displayName ?? undefined, preferred_currency_code: 'USD', meta_data: { organizationId, customerType: 'organization', },});
await setChargebeeCustomerId(organizationId, customer.id);Return 200 after enqueueing work. Scalekit retries on non-2xx responses.
3. Read the organization ID from the session
Section titled “3. Read the organization ID from the session”Billing routes need the org context from the access token. Validate the token on every request and require the oid claim:
import { decodeJwt } from 'jose';
const isValid = await scalekit.validateAccessToken(accessToken);if (!isValid) { throw new SessionError(401, 'Invalid or expired token');}
// After successful validation, decode to read claims (e.g. `oid`, `sub`).// decodeJwt is safe here because validateAccessToken already performed// cryptographic signature validation + standard claim checks (exp, iss, aud).const claims = decodeJwt(accessToken);
const organizationId = claims.oid as string | undefined;if (!organizationId) { throw new SessionError(403, 'Organization context required for billing');}
return { userId: claims.sub as string, email: claims.email as string, organizationId,};Do not call /userinfo for billing context. Use scalekit.validateAccessToken(accessToken) (boolean) followed by a JWT decode (e.g. decodeJwt from jose) to read the oid claim from the access token. This matches the recommended pattern in the access control guide.
4. Authorize billing actions per organization
Section titled “4. Authorize billing actions per organization”Before any Chargebee API call, confirm the caller’s session org matches the billing reference:
export async function authorizeReference({ organizationId, referenceId,}: { organizationId: string; referenceId: string;}): Promise<boolean> { return referenceId === organizationId;}Extend this hook to deny billing for specific orgs (trial abuse, delinquent accounts) without changing Chargebee configuration.
5. Create a future subscription and start hosted checkout
Section titled “5. Create a future subscription and start hosted checkout”When an org admin clicks Subscribe, create a local future row first, then call Chargebee hostedPage.checkoutNewForItems:
const referenceId = body.referenceId ?? ctx.organizationId;if (!(await authorizeReference({ organizationId: ctx.organizationId, referenceId }))) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 });}
const customerId = await getOrCreateCustomerId({ organizationId: referenceId });const localSub = await createFutureSubscription({ referenceId, chargebeeCustomerId: customerId,});
await chargebee.customer.update(customerId, { meta_data: { pendingSubscriptionId: localSub.id, organizationId: referenceId, },});
const result = await chargebee.hostedPage.checkoutNewForItems({ subscription_items: [{ item_price_id: planItemPriceId, quantity: seats }], customer: { id: customerId }, redirect_url: `${appUrl}/api/subscription/success?callbackURL=/billing?success=1&subscriptionId=${localSub.id}`, cancel_url: `${appUrl}/billing`,});
return NextResponse.json({ mode: 'hosted', url: result.hosted_page.url });The future row gives your app a stable ID to reconcile against before Chargebee assigns a subscription ID.
6. Sync subscription state from Chargebee webhooks
Section titled “6. Sync subscription state from Chargebee webhooks”Register a Chargebee webhook endpoint with HTTP Basic Auth. Handle at minimum:
| Chargebee event | Action |
|---|---|
subscription_created | Link chargebee_subscription_id, set status |
subscription_activated / subscription_started | Mark active or in_trial, fire entitlements hook |
subscription_changed / subscription_renewed | Update plan, seats, period end |
subscription_cancelled | Mark cancelled, revoke entitlements |
customer_deleted | Clear local mapping |
Lookup order when matching a webhook to a local row:
chargebee_subscription_idon the local rowmeta_data.subscriptionIdon the Chargebee subscriptionmeta_data.pendingSubscriptionIdon the Chargebee customerfuturerow byreference_id
7. Eager-sync on checkout redirect
Section titled “7. Eager-sync on checkout redirect”Hosted checkout redirects to your success URL before webhooks arrive. Add an eager sync in the success handler so the billing page shows the subscription immediately:
export async function GET(request: NextRequest) { const subscriptionId = request.nextUrl.searchParams.get('subscriptionId');
if (subscriptionId) { const local = await findSubscriptionById(subscriptionId); if (local?.chargebeeSubscriptionId) { const result = await chargebee.subscription.retrieve(local.chargebeeSubscriptionId); await syncLocalFromChargebeeSubscription(local, result.subscription); } }
return NextResponse.redirect(new URL('/billing?success=1', request.url));}Webhooks remain the source of truth for ongoing changes. The redirect sync removes the “refresh and wait” gap after checkout.
8. Gate features from local subscription state
Section titled “8. Gate features from local subscription state”Read subscription status from your database, not from Chargebee on every request:
const subs = await findActiveByReferenceId(ctx.organizationId);return NextResponse.json({ subscriptions: subs.map((sub) => ({ id: sub.id, status: sub.status, planId: sub.planId, seats: sub.seats, trialEnd: sub.trialEnd, })),});Use onSubscriptionComplete and onSubscriptionDeleted hooks to flip feature flags, enable SSO, or send onboarding email when status changes.
Testing
Section titled “Testing”Run this five-minute validation script after wiring both webhook endpoints through a tunnel:
- Create an organization in Scalekit (or fire
organization.createdvia the dashboard). - Confirm provisioning — local
organizationrow exists and Chargebee dashboard shows a customer with matchingorganizationIdmetadata. - Sign in as a user in that org and open your billing page.
- Start checkout —
POST /api/subscription/createreturns{ mode: 'hosted', url }. Complete payment with test card4111 1111 1111 1111. - Confirm redirect — browser lands on
/billing?success=1and the subscription appears without a manual refresh. - Replay a webhook — send a test
subscription_activatedevent from the Chargebee dashboard and confirm the local row updates.
curl -s http://localhost:3000/api/session \ -H "Cookie: scalekit_session=<your-session-cookie>" | jq '.organizationId'Common mistakes
Section titled “Common mistakes”no_applicable_gatewayon hosted checkout — Chargebee cannot select a payment gateway. Add a test gateway in the Chargebee dashboard, setCHARGEBEE_GATEWAY_ACCOUNT_ID, or enable Smart Routing.- Checkout succeeds but no redirect —
NEXT_PUBLIC_APP_URLmust appear in Chargebee Allowed redirect domains. A declined test card also prevents redirect. - Webhook signature failures — reading
req.json()before verification mutates the body. Use the raw body string for Scalekit; use Basic Auth for Chargebee. - Duplicate Chargebee customers — race between org webhook and first checkout click. Make
createOrgCustomeridempotent: check the local mapping before callingcustomer.create. - Billing API returns 403 —
referenceIdin the request body does not match sessionoid. In org-mode v1, always pass the session organization ID.
Production notes
Section titled “Production notes”- Replace SQLite with Postgres or your production database. Keep the
reference_idindex — webhook handlers query by org ID on every event. - Rotate webhook secrets independently for Scalekit and Chargebee. Store them in your secrets manager, not
.envfiles in the image. - Make handlers idempotent — Chargebee retries webhooks;
subscription_activatedmay arrive twice. Upsert bychargebee_subscription_id, do not insert blindly. - Handle org deletion — on
organization.deleted, cancel active Chargebee subscriptions and delete local rows. Orphan subscriptions continue billing otherwise. - Do not expose Chargebee API keys client-side — only publishable keys belong in
NEXT_PUBLIC_*variables for Chargebee.js. Server routes call the Chargebee SDK with the secret key.
Next steps
Section titled “Next steps”- Clone the saas-auth-chargebee-example repo for a runnable Next.js implementation
- Enforce seat limits with SCIM provisioning when billing plans cap user count
- External IDs and metadata for mapping Scalekit orgs to your internal tenant IDs
- Organization webhook events for org lifecycle payloads
- Chargebee webhook documentation for the full event catalog