Overview
Map Scalekit organizations to Chargebee customers, run hosted checkout, and keep subscription state in sync with webhooks.
Multi-tenant B2B SaaS apps usually sign users in through Scalekit organizations but bill through Chargebee subscriptions. The two systems don't share a database, so you connect them yourself. Without a clear mapping between them, you get duplicate Chargebee customers, subscriptions that never turn active after checkout, or feature gates (the checks that unlock paid features) that read stale plan data.
This cookbook connects Scalekit organizations and sessions to Chargebee using org-mode billing, where you bill each organization as a whole rather than each user. The organization ID from the user's access token (the oid claim) becomes the billing reference (referenceId) that ties the two systems together. Scalekit webhooks create the Chargebee customers, and Chargebee webhooks keep your local subscription table current. You own the routes, database schema, and authorization that connect the two systems.
Working example repo
Patterns below are implemented end-to-end in saas-auth-chargebee-example (Next.js, Scalekit, Chargebee Node SDK, Drizzle + SQLite). Clone it to follow along locally, including the in-app journey at /guide.
What you get
- Chargebee customers created when Scalekit fires the
organization.createdevent, not on every user signup - A local mapping between each Scalekit organization and its Chargebee customer, keyed by the Scalekit organization ID
- Hosted checkout and a customer portal through Chargebee hosted pages, so you don't build payment UI yourself
- A local subscription cache kept current by Chargebee webhooks, plus an optional eager sync right after checkout
- Session-scoped authorization, so a billing request can act only on the caller's own organization (
referenceId === oid) - Lifecycle hooks where you add your own product logic (after a customer is created, after a subscription completes or cancels, and when you deny a billing action)
Who needs this
This cookbook is for you if:
- ✅ You sign users in with Scalekit and use organizations (the
oidclaim is in your access tokens) - ✅ You bill per organization, not per user
- ✅ You use Chargebee hosted checkout or the customer portal
- ✅ You keep a local subscription cache to gate features in your app
You don't need this if:
- ❌ You bill per user instead of per organization
- ❌ Scalekit manages your entire product catalog and entitlements, and you have no separate billing system
How the integration fits together
Treat the Scalekit organization ID as the single billing reference for each tenant. Scalekit signs the user in and identifies their organization, your app owns the mapping and the local subscription cache, and Chargebee owns the catalog, checkout, and billing state.
The integration runs as four flows. Each flow starts from a different event, and each one has its own sequence diagram and step-by-step walkthrough later in this cookbook:
- Provision billing when an organization is created — Scalekit tells your app about a new organization, so you create its Chargebee customer and save the mapping.
- Authenticate the user and load the organization session — a user signs in with Scalekit, so you read the organization ID (
oid) from their access token and use it as the billing identity. - Start hosted checkout — an admin subscribes, so you create a placeholder subscription and hand off to Chargebee's hosted checkout page.
- Reconcile subscription state — Chargebee reports a change, so you update your local subscription cache to keep your app's feature gates accurate.
The reference demo includes the complete product UI, so you can compare each flow with a working application.
Before you start
Set up these prerequisites once before you write any code. Each row lists what you need and where to get it.
| 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 (for example growth-plan-monthly) | Chargebee Product Catalog — reference prices by ID in code |
Test payment gateway (gw_...) | Chargebee Payment Gateways |
| Public tunnel for webhooks | ngrok, LocalTunnel, or similar |
Setup
Before you build the flows, get the project ready. These four steps run once and don't change while the app is running. Do them in order.
Step 1: Install packages
Install the Scalekit and Chargebee SDKs on the server, where your API routes and webhook handlers run. Use whichever package manager your app uses (npm, pnpm, or yarn). The example below matches the reference app:
npm install @scalekit-sdk/node chargebee
The snippets in this cookbook use Node.js and the Next.js App Router, to match the reference app. The Scalekit concepts (token validation, webhook verification, and the oid claim) apply to every Scalekit SDK, so adapt the routes and session storage if you use another stack. This cookbook uses the Chargebee Node SDK.
Use any ORM for the local billing tables. The reference app uses Drizzle with SQLite. Keep your Chargebee secret API keys on the server only. Publishable keys for Chargebee.js can use a NEXT_PUBLIC_ prefix if you embed payment components in the browser.
Step 2: Configure environment variables
Define these variables in your server environment. Use a .env file locally, and your host's secrets store in production.
| Variable | Purpose |
|---|---|
SCALEKIT_ENV_URL | Scalekit environment URL |
SCALEKIT_CLIENT_ID / SCALEKIT_CLIENT_SECRET | OAuth client |
SCALEKIT_REDIRECT_URI | OAuth callback (for example http://localhost:3000/auth/callback) |
SCALEKIT_SCOPES | OAuth scopes to request at sign-in (defaults to openid profile email offline_access) |
SCALEKIT_WEBHOOK_SECRET | Verify Scalekit webhook signatures |
CHARGEBEE_SITE | Chargebee site subdomain |
CHARGEBEE_API_KEY | Full-access API key for your Chargebee site |
CHARGEBEE_PLAN_ITEM_PRICE_ID | Default plan item price ID from Product Catalog 2.0 |
CHARGEBEE_GATEWAY_ACCOUNT_ID | Optional gateway pin for hosted checkout (gw_...) |
CHARGEBEE_WEBHOOK_USERNAME / CHARGEBEE_WEBHOOK_PASSWORD | Basic Auth for Chargebee webhooks (recommended in production) |
NEXT_PUBLIC_APP_URL | App base URL for hosted-page redirects |
DATABASE_URL | Connection string for your local billing database (for example file:./data/billing.db for SQLite) |
SCALEKIT_ENV_URL=https://your-env.scalekit.dev
SCALEKIT_CLIENT_ID=skc_...
SCALEKIT_CLIENT_SECRET=
SCALEKIT_REDIRECT_URI=http://localhost:3000/auth/callback
SCALEKIT_SCOPES=openid profile email offline_access
SCALEKIT_WEBHOOK_SECRET=
CHARGEBEE_SITE=your-site-test
CHARGEBEE_API_KEY=
CHARGEBEE_PLAN_ITEM_PRICE_ID=growth-plan-monthly
CHARGEBEE_GATEWAY_ACCOUNT_ID=gw_your_test_gateway_id
CHARGEBEE_WEBHOOK_USERNAME=
CHARGEBEE_WEBHOOK_PASSWORD=
NEXT_PUBLIC_APP_URL=http://localhost:3000
DATABASE_URL=file:./data/billing.db
Step 3: Add local schema
Add tables for organizations, subscriptions, and optional line items. The organization row holds the Chargebee customer ID. Subscriptions are keyed by reference_id, which is the Scalekit organization ID from the oid claim. Give new subscriptions the status future by default, so checkout has a local row to match against before Chargebee assigns its own subscription ID.
| Model | Role |
|---|---|
organization | Holds an optional chargebee_customer_id; the primary key is the Scalekit organization ID |
subscription | reference_id (organization), Chargebee subscription and customer IDs, status, trial and period dates, seats, metadata |
subscription_item | Line items (plans, add-ons, and charges) with quantity and pricing details |
Source of truth: Chargebee owns the catalog, invoices, and payment state. Your database is a cache for authorization and your UI.
Show the schema
CREATE TABLE organization (
id TEXT PRIMARY KEY,
display_name TEXT,
chargebee_customer_id TEXT UNIQUE,
updated_at INTEGER
);
CREATE TABLE subscription (
id TEXT PRIMARY KEY,
reference_id TEXT NOT NULL,
chargebee_customer_id TEXT,
chargebee_subscription_id TEXT UNIQUE,
status TEXT NOT NULL DEFAULT 'future',
period_start INTEGER,
period_end INTEGER,
trial_start INTEGER,
trial_end INTEGER,
canceled_at INTEGER,
seats INTEGER,
metadata TEXT
);
CREATE TABLE subscription_item (
id TEXT PRIMARY KEY,
subscription_id TEXT NOT NULL REFERENCES subscription(id) ON DELETE CASCADE,
item_price_id TEXT NOT NULL,
item_type TEXT NOT NULL,
quantity INTEGER NOT NULL,
unit_price INTEGER,
amount INTEGER
);
Translate this schema to your ORM. Add an index on reference_id, because webhook handlers and list endpoints query by organization ID on every request. The reference app models these tables with Drizzle in lib/db/schema.ts. After you change the schema, migrate your app database the same way you migrate any other table. There's no separate billing CLI.
After this step: you've applied the migrations, and the empty tables are ready for provisioning and checkout.
Step 4: Initialize clients
Create each client once and reuse it. These helpers build the client the first time you call them, then return the same instance on later calls (a pattern called a lazy singleton). They read the keys from environment variables, so never hardcode API keys.
Show the client setup
import { ScalekitClient } from '@scalekit-sdk/node'
let scalekitClient: ScalekitClient | null = null
export function getScalekitClient(): ScalekitClient {
if (!scalekitClient) {
const envUrl = process.env.SCALEKIT_ENV_URL
const clientId = process.env.SCALEKIT_CLIENT_ID
const clientSecret = process.env.SCALEKIT_CLIENT_SECRET
if (!envUrl || !clientId || !clientSecret) {
throw new Error('Set SCALEKIT_ENV_URL, SCALEKIT_CLIENT_ID, and SCALEKIT_CLIENT_SECRET.')
}
scalekitClient = new ScalekitClient(envUrl, clientId, clientSecret)
}
return scalekitClient
}
import Chargebee from 'chargebee'
let chargebeeClient: Chargebee | null = null
export function getChargebeeClient(): Chargebee {
if (!chargebeeClient) {
const site = process.env.CHARGEBEE_SITE
const apiKey = process.env.CHARGEBEE_API_KEY
if (!site || !apiKey) {
throw new Error('Set CHARGEBEE_SITE and CHARGEBEE_API_KEY.')
}
chargebeeClient = new Chargebee({ site, apiKey })
}
return chargebeeClient
}
These helpers match lib/scalekit.ts and lib/chargebee.ts in the reference app.
Flow 1: Provision billing when an organization is created
This flow runs on its own, before anyone signs in. When someone creates an organization in Scalekit, Scalekit sends your app an organization.created webhook. Your app then creates a matching Chargebee customer and stores the link between them, so the organization is ready to bill later.
Register a Scalekit webhook for organization.created, organization.updated, and organization.deleted, and point it at your public URL (use a tunnel such as ngrok in local dev):
https://YOUR-DOMAIN.com/api/webhooks/scalekit
Show the step-by-step walkthrough
Step 1: Scalekit tells your app a new organization was created. Scalekit sends a POST request to your webhook route. Read the raw request body first — you need the exact bytes Scalekit sent so you can check the signature in the next step.
export async function POST(req: NextRequest) {
const rawBody = await req.text()
const secret = process.env.SCALEKIT_WEBHOOK_SECRET
// ...
}
Source: app/api/webhooks/scalekit/route.ts.
Step 2: Confirm the message really came from Scalekit. Verify the signature against the raw body. If it doesn't match, reply 401 and stop — the request isn't from Scalekit.
Verify on the raw body
Never parse the body before you verify it. Re-serializing JSON changes the bytes and breaks the signature check. Read req.text() first, verify, then call JSON.parse.
const isValid = client.verifyWebhookPayload(secret, headersToRecord(req), rawBody)
if (!isValid) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}
Source: app/api/webhooks/scalekit/route.ts.
Step 3: Accept the message right away. Reply 200 as soon as the signature checks out, then finish the real work in the background with setImmediate. Scalekit retries on any non-2xx response, so a fast acknowledgement avoids duplicate deliveries. The dispatcher routes the event by type, and organization.created calls createOrgCustomer.
const event = JSON.parse(rawBody)
setImmediate(() => {
dispatchScalekitEvent(event).catch((err) => {
console.error('[scalekit-webhook] Async handler failed', err)
})
})
return NextResponse.json({ received: true }, { status: 200 })
Source: app/api/webhooks/scalekit/route.ts and the dispatcher in app/api/webhooks/scalekit/handlers.ts.
Step 4: Save the organization. In the background, createOrgCustomer first upserts the organization into your database, so you always have a local record even if a later step fails.
await upsertOrganization({
id: organizationId,
displayName: displayName ?? null,
})
Source: lib/billing/create-org-customer.ts.
Step 5: Check whether it already has a billing customer. If this organization already has a Chargebee customer, stop and reuse it. This keeps the handler idempotent — safe to run more than once — so retries don't create duplicate customers.
const existing = await getOrganizationById(organizationId)
if (existing?.chargebeeCustomerId) {
return existing.chargebeeCustomerId
}
Source: lib/billing/create-org-customer.ts.
Step 6: Create a billing customer for the organization. Create the Chargebee customer and store the organization ID in its meta_data. That metadata is how you match Chargebee events back to the organization later, in Flow 4.
const { customer } = await chargebee.customer.create({
company: displayName ?? undefined,
email: email ?? undefined,
preferred_currency_code: 'USD',
meta_data: {
organizationId,
customerType: 'organization',
},
})
Source: lib/billing/create-org-customer.ts.
Step 7: Chargebee returns the new customer. If a checkout raced ahead and already linked a customer, delete the duplicate you just created and keep the existing one. This guards against the webhook and a first checkout arriving at nearly the same time.
const refreshed = await getOrganizationById(organizationId)
if (refreshed?.chargebeeCustomerId) {
if (refreshed.chargebeeCustomerId !== customer.id) {
await chargebee.customer.delete(customer.id)
}
return refreshed.chargebeeCustomerId
}
Source: lib/billing/create-org-customer.ts.
Step 8: Link the customer to the organization. Save the Chargebee customer ID on the local organization row, then run the onCustomerCreate hook for your own product logic, such as analytics or CRM sync.
await setChargebeeCustomerId(organizationId, customer.id)
await onCustomerCreate({
organizationId,
chargebeeCustomerId: customer.id,
displayName,
})
Source: lib/billing/create-org-customer.ts.
After this flow: create an organization in Scalekit, and a local organization row plus a Chargebee customer with matching organizationId metadata appear — before anyone signs in.
In the reference app dashboard, the first setup items show Done and the organization is linked to a Chargebee customer before you open billing:
Flow 2: Authenticate the user and load the organization session
Billing always happens on behalf of an organization, so every billing request needs to know which organization the user belongs to. That identity lives in the user's access token as the oid (organization ID) claim. This flow signs the user in with Scalekit using OAuth — the standard "sign in with another service" handshake — and stores a session your app can read on later requests.
Show the step-by-step walkthrough
Step 1: The user selects sign in. The sign-in button asks your app for a sign-in link, then sends the browser to it.
const response = await fetch('/api/auth/login')
const data = await response.json()
if (data.authUrl) {
window.location.href = data.authUrl
}
Source: components/LoginButton.tsx.
Step 2: Your app creates a one-time security value. The login route makes a random state value and stores it. The state protects against CSRF (cross-site request forgery), where an attacker tries to finish someone else's sign-in. You check it again when the user comes back.
const state = crypto.randomBytes(32).toString('base64url')
await setOAuthState(state)
Source: app/api/auth/login/route.ts.
Step 3: Your app returns the Scalekit sign-in link. Build the authorization URL with the state and the scopes you need, then send it back to the browser.
const authUrl = client.getAuthorizationUrl(redirectUri, {
state,
scopes: getDefaultScopes(),
})
return NextResponse.json({ authUrl })
Source: app/api/auth/login/route.ts.
Step 4: The user signs in at Scalekit. The browser follows the link to Scalekit, where the user signs in. Scalekit handles the credentials, so your app never sees a password. When sign-in succeeds, Scalekit redirects the browser to your SCALEKIT_REDIRECT_URI.
Step 5: Scalekit sends the user back with a temporary code. The redirect includes a short-lived code and the same state you sent. The code is a one-time voucher you'll trade for tokens.
Step 6: The user returns to your app with the code. Your callback route reads the code and state from the query string.
const { searchParams } = new URL(request.url)
const code = searchParams.get('code')
const state = searchParams.get('state')
Source: app/api/auth/callback/route.ts.
Step 7: Confirm the security value matches. Compare the returned state to the one you stored in Step 2. If they don't match, reject the request. Then clear the stored value so it can't be reused.
const storedState = getOAuthState()
if (!state || state !== storedState) {
return NextResponse.redirect(/* ...error... */)
}
await clearOAuthState()
Source: app/api/auth/callback/route.ts.
Step 8: Exchange the code for tokens. Send the code back to Scalekit to get the user's tokens. Because the code is single-use, this can happen only once.
const authResponse = await client.authenticateWithCode(code, redirectUri)
Source: app/api/auth/callback/route.ts.
Step 9: Scalekit returns the tokens. You get an access token, a refresh token, and an ID token. The access token is a JWT (a signed token) whose claims include the organization ID (oid) you use for billing. Validate the token before you trust its claims.
const claims = await client.validateToken(authResponse.accessToken)
Source: app/api/auth/callback/route.ts.
Step 10: Save the session. Store the user and tokens in a session cookie so later requests don't repeat the handshake.
await setSession({
user: {
/* sub, email, name, ... */
},
tokens: {
access_token: authResponse.accessToken,
refresh_token: authResponse.refreshToken,
id_token: authResponse.idToken,
expires_at: expiresAt.toISOString(),
expires_in: expiresIn,
},
roles: claims.roles || [],
permissions: claims.permissions || [],
})
Source: app/api/auth/callback/route.ts.
Step 11: Open the dashboard. Redirect the signed-in user to the dashboard.
return NextResponse.redirect(new URL('/dashboard', request.url))
Source: app/api/auth/callback/route.ts.
After this flow: on every later billing request, requireSession validates the current access token (refreshing it first if it expired), then reads oid from the claims. Read oid from the validated token — don't call a separate userinfo endpoint for billing context. That oid is the tenant's billing identity in Flows 3 and 4, and billing is refused with 403 if it's missing.
const claims = await client.validateToken<TokenClaims>(accessToken)
const organizationId = claims.oid
if (!organizationId) {
throw new SessionError(403, 'Organization context required for billing')
}
Source: lib/auth/require-session.ts.
Flow 3: Start hosted checkout with a placeholder subscription
When an organization admin subscribes to a plan, you hand off to Chargebee's hosted checkout page, which collects payment for you. Before the redirect, you create a placeholder subscription in your database with status future. That gives you a stable ID to reconcile against when Chargebee reports back, even before Chargebee has assigned its own subscription ID.
Show the step-by-step walkthrough
Step 1: The admin selects Subscribe. The billing page shows plans scoped to the session's organization. Selecting Subscribe posts to your create route with the plan's item price ID.
<button
type="button"
className="btn btn-primary mt-auto"
disabled={loading}
onClick={() => onSelect(plan.itemPriceId)}
>
{loading ? 'Starting checkout…' : 'Subscribe'}
</button>
Source: components/PlanPicker.tsx.
Step 2: Confirm the session and that the caller owns this organization. Load the session, then authorize the action. authorizeReference requires the billing referenceId to equal the caller's oid, so an admin can only bill their own organization. This is org-mode billing: the organization is the unit that pays.
const ctx = await requireSession()
const body = (await request.json()) as CreateBody
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 })
}
Source: app/api/subscription/create/route.ts, with the ownership check in lib/auth/authorize-reference.ts.
Step 3: Check there's no active subscription yet. If the organization already has an active subscription, stop with a clear error. This prevents accidental double-subscribing.
const active = await findActiveByReferenceId(referenceId)
if (active) {
return NextResponse.json(
{ error: 'An active subscription already exists for this organization' },
{ status: 400 }
)
}
Source: app/api/subscription/create/route.ts.
Step 4: Make sure the organization has a billing customer. Reuse the Chargebee customer from Flow 1, or create it now if the webhook hasn't arrived yet. Either way, you end up with a customer ID to attach the subscription to.
const customerId = await getOrCreateCustomerId({
organizationId: referenceId,
email: ctx.email,
})
Source: app/api/subscription/create/route.ts and lib/billing/get-or-create-customer.ts.
Step 5: Create a placeholder subscription. Insert a local row with status future. This placeholder gives you a stable ID to match against when Chargebee reports back, before Chargebee has assigned its own subscription ID.
localSub = await createFutureSubscription({
referenceId,
chargebeeCustomerId: customerId,
})
await updateSubscription(localSub.id, { seats })
Source: app/api/subscription/create/route.ts.
Step 6: Leave a note on the customer pointing to the placeholder. Stamp pendingSubscriptionId (your placeholder's ID) onto the Chargebee customer's metadata. It's a breadcrumb that lets webhooks find the right local row in Flow 4.
await chargebee.customer.update(customerId, {
preferred_currency_code: 'USD',
meta_data: {
pendingSubscriptionId: localSub.id,
pendingReferenceId: referenceId,
userId: ctx.userId,
organizationId: referenceId,
},
})
Source: app/api/subscription/create/route.ts.
Step 7: Ask Chargebee for a hosted checkout page. Create the hosted page with your plan's item price IDs. Set redirect_url to your success route (carrying the placeholder ID) and cancel_url to your billing 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),
})
Source: app/api/subscription/create/route.ts. The success redirect embeds the placeholder ID so you can sync it after payment, built in lib/billing/checkout.ts.
Step 8: Chargebee returns the checkout link. Chargebee replies with a hosted page object that already knows the plan, customer, and redirect URLs. Read its id and url so you can hand the URL to the browser in the next step.
const hostedPageId = result.hosted_page.id ?? ''
const hostedPageUrl = result.hosted_page.url ?? ''
Source: app/api/subscription/create/route.ts.
Step 9: Send the admin to the checkout page. Return the URL to the client, which sends the browser to Chargebee.
return NextResponse.json({
mode: 'hosted',
url: hostedPageUrl,
id: hostedPageId,
})
Source: app/api/subscription/create/route.ts.
Step 10: The admin enters payment details and pays. On Chargebee's hosted page, the admin reviews the plan and pays. In the sandbox, use the test card 4111 1111 1111 1111.
Step 11: Chargebee sends the admin back to your success page. After payment, Chargebee redirects to your success route with the placeholder ID and the callback URL you set.
const callbackURL = request.nextUrl.searchParams.get('callbackURL') ?? '/billing?success=1'
const subscriptionId = request.nextUrl.searchParams.get('subscriptionId')
Source: app/api/subscription/success/route.ts.
Step 12: Fetch the finished subscription. Webhooks may not have arrived yet, so fetch the subscription straight from Chargebee. If the placeholder is already linked, retrieve it by ID; otherwise list the customer's subscriptions and take the latest. This is the eager sync that makes the UI feel instant.
if (local.chargebeeSubscriptionId) {
const result = await chargebee.subscription.retrieve(local.chargebeeSubscriptionId)
await syncLocalFromChargebeeSubscription(local, result.subscription)
return
}
const result = await chargebee.subscription.subscriptionsForCustomer(
local.chargebeeCustomerId,
{ limit: 10 }
)
Source: app/api/subscription/success/route.ts.
Step 13: Update the placeholder with the real subscription. Copy Chargebee's status, period dates, trial dates, and seats onto your local row. Chargebee is the source of truth; your row is a cache.
const updated = await updateSubscription(local.id, {
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),
seats: extractSeats(cbSub),
// ...
})
Source: lib/billing/subscription-lifecycle.ts.
Step 14: Show the billing page with the new subscription. Redirect to the billing page. Validate the callback URL first so an attacker can't turn it into an open redirect off your site.
const safePath = validateCallbackUrl(callbackURL)
return NextResponse.redirect(new URL(safePath, request.url))
Source: app/api/subscription/success/route.ts. The guard allows only same-origin relative paths, in lib/billing/checkout.ts.
After this flow: completing checkout in the sandbox creates the Chargebee subscription, the eager sync updates your local row, and the billing page shows the subscription without a manual refresh. Webhooks (Flow 4) remain the source of truth for every later change.
Flow 4: Reconcile subscription state from Chargebee webhooks
Chargebee is the source of truth for billing. A subscription can change from many places — a successful payment, a plan change, a renewal, a cancellation, or a failed charge — and not all of those start in your app. To keep your local cache correct, Chargebee sends your app a webhook whenever a subscription changes, and you update your database to match.
In the Chargebee dashboard, create a webhook endpoint that points to your public URL:
https://YOUR-DOMAIN.com/api/webhooks/chargebee
Protect the route with HTTP Basic Auth — a username and password sent with each request — using CHARGEBEE_WEBHOOK_USERNAME and CHARGEBEE_WEBHOOK_PASSWORD, and enter the same credentials under Basic Authentication in the Chargebee webhook settings. Subscribe at least to these events (names as in Chargebee and the Node SDK; see event types):
| Chargebee event | Action |
|---|---|
subscription_created | Link chargebee_subscription_id, set status |
subscription_activated / subscription_started | Mark active or in_trial, run entitlements hooks |
subscription_changed / subscription_renewed | Update plan, seats, period dates |
subscription_cancelled | Mark cancelled, revoke entitlements |
customer_deleted | Clear local customer mapping |
Show the step-by-step walkthrough
Step 1: Chargebee reports that a subscription changed. Chargebee sends a POST request to your webhook route for each event you subscribed to.
export async function POST(req: NextRequest) {
const validator = getBasicAuthValidator()
const headers = headersToRecord(req)
// ...
}
Source: app/api/webhooks/chargebee/route.ts.
Step 2: Check the request's username and password. Validate Basic Auth before doing anything else. If it fails, reply 401 and stop.
if (validator) {
try {
await validator(headers)
} catch (err) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
}
Source: app/api/webhooks/chargebee/route.ts.
Step 3: Work out which kind of change happened. Parse the event, then route it by event_type. Each type maps to a handler: created, activated or started, changed or renewed, cancelled, and customer deleted.
switch (event.event_type) {
case WebhookEventType.SubscriptionCreated:
await onSubscriptionCreatedEvent(event)
break
case WebhookEventType.SubscriptionActivated:
case WebhookEventType.SubscriptionStarted:
await onSubscriptionCompleteEvent(event)
break
// ...changed / renewed / cancelled / customer_deleted
}
Source: app/api/webhooks/chargebee/route.ts dispatches to the router in lib/billing/chargebee-webhook-handler.ts.
Step 4: Find the matching local subscription. Right after checkout, Chargebee's IDs and your placeholder might not be linked yet, so try four lookups in order and use the first match:
- By
chargebee_subscription_idon the local row. - By the local ID stamped in the subscription's metadata.
- By the customer's
pendingSubscriptionIdbreadcrumb from Flow 3. - By the
futurerow for the organization (reference_id).
async function resolveLocalSubscription(cbSub, customer) {
const byChargebeeId = await findByChargebeeSubscriptionId(cbSub.id)
if (byChargebeeId) return byChargebeeId
// 2) subscription meta_data.subscriptionId
// 3) customer meta_data.pendingSubscriptionId
// 4) future row by reference_id (organization ID)
}
Source: lib/billing/subscription-lifecycle.ts.
Step 5: Update its status, dates, and seats. Copy Chargebee's fields onto the local row. This is the same update the eager sync uses in Flow 3, so both paths converge on the same state.
const updated = await updateSubscription(local.id, {
chargebeeSubscriptionId: cbSub.id,
status: cbSub.status,
periodStart: unixToDate(cbSub.current_term_start),
periodEnd: unixToDate(cbSub.current_term_end),
seats: extractSeats(cbSub),
// ...
})
Source: lib/billing/subscription-lifecycle.ts.
Step 6: Refresh the plan line items. Replace the stored line items with the ones on the Chargebee subscription, so feature gates that read seats or plan always see current data.
await deleteSubscriptionItems(localSubscriptionId)
const items = mapSubscriptionItems(cbSub)
if (items.length > 0) {
await insertSubscriptionItems(localSubscriptionId, items)
}
Source: lib/billing/subscription-lifecycle.ts.
Step 7: Confirm it saved the change. Reply 200 after the writes succeed. If a write throws, reply 500 so Chargebee retries later — don't drop the event.
try {
await dispatchChargebeeEvent(event)
return NextResponse.json({ received: true }, { status: 200 })
} catch (err) {
return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 })
}
Source: app/api/webhooks/chargebee/route.ts.
Chargebee retries on failure
Return 500 when your handler fails so Chargebee retries, and return 200 only after the database write succeeds. Scalekit organization webhooks (Flow 1) can return 200 immediately and process in the background — failure modes differ by provider.
After this flow: cancel or change a plan in Chargebee, and the local subscription status updates without a page-refresh loop.
Define plans
You can provision customers without a plan catalog in your code. To sell plans, map each Chargebee item price ID (Chargebee's ID for a specific price of a plan) to a name, limits, and an optional free trial. Let Chargebee own the pricing, and store your entitlement metadata (the limits and features each plan unlocks) in your app.
Define the plans in code. This is fine for a small catalog:
export type PlanConfig = {
itemPriceId: string
name: string
limits: Record<string, number>
freeTrial?: { days: number }
}
export const PLANS: PlanConfig[] = [
{
itemPriceId: process.env.CHARGEBEE_PLAN_ITEM_PRICE_ID ?? 'growth-plan-monthly',
name: 'Growth',
limits: { seats: 25 },
freeTrial: { days: 14 },
},
]
For a larger or changing catalog, load plans from your database instead, so marketing copy and price IDs stay out of source control. Map each row to the same PlanConfig shape. If the query fails, deny access rather than grant it (fail closed).
Common tasks
Use the section that matches your task. Each answer assumes the Setup steps and the four flows above are in place (schema, webhooks, authorization, and hosted checkout).
How do I start hosted checkout for a plan?
Call your create route from the browser with the Chargebee item price ID and your redirect URLs. The server authorizes the request, creates a local future row, and returns a hosted checkout URL for you to redirect to.
const res = await fetch('/api/subscription/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
itemPriceId: 'growth-plan-monthly',
successUrl: '/billing?success=1',
cancelUrl: '/billing',
}),
})
const data = await res.json()
if (data.url) {
window.location.href = data.url
}
How do I list active subscriptions for the current organization?
Read from your local cache, not from Chargebee, on every request. Authorize the reference first, then read the local rows and keep the active ones. Use that result for your billing UI and feature gates.
const authorized = await authorizeReference({
userId: ctx.userId,
organizationId: ctx.organizationId,
referenceId: ctx.organizationId,
action: 'list',
})
if (!authorized) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const subs = await findSubscriptionsByReferenceId(ctx.organizationId)
const active = subs.filter((sub) => isActiveOrTrialing(sub.status))
return NextResponse.json(active)
How do I change plans when a subscription already exists?
Use the update route in app/api/subscription/update/route.ts. It authorizes the reference, finds the active subscription, and then opens a Chargebee hosted page for the existing chargebee_subscription_id by calling chargebee.hostedPage.checkoutExistingForItems.
Don't call the create route again while an active subscription exists. It returns 400 with "an active subscription already exists for this organization", so send the user to the update flow or the billing portal instead.
How do I send users to the Chargebee billing portal?
Create a portal session for the organization's Chargebee customer, then redirect to its access_url so the user can manage payment methods and invoices.
// After authorizeReference(..., action: 'portal')
const portalSession = await chargebee.portalSession.create({
customer: { id: chargebeeCustomerId },
redirect_url: absoluteUrl(returnUrl),
})
return NextResponse.json({
url: portalSession.portal_session.access_url,
})
How do I gate a feature behind an active plan?
Check the local subscription status, and the plan line items if you store them. The reference app's isActiveOrTrialing helper treats active, in_trial, and non_renewing as entitled. Change that set if your product rules differ.
This helper doesn't exist in the reference app, so add it yourself. It reuses findSubscriptionsByReferenceId and isActiveOrTrialing from the reference code:
import { findSubscriptionsByReferenceId } from '@/lib/db/subscriptions'
import { isActiveOrTrialing } from '@/lib/billing/utils'
async function requireActivePlan(organizationId: string): Promise<boolean> {
const subs = await findSubscriptionsByReferenceId(organizationId)
// Join subscription_item or stored plan metadata to match a specific plan.
return subs.some((sub) => isActiveOrTrialing(sub.status))
}
How do I restrict who can create or update organization billing?
Org-mode billing already scopes every request to the session's oid through authorizeReference. To tighten it further, combine that check with a role check (such as owner or admin) from the Scalekit token or your own membership store before you call Chargebee.
How do I run custom logic at key billing events?
Use the optional hooks in lib/subscription-hooks.ts to keep your product logic out of the webhook routes. Fill in the body of each hook you need. Each one runs at a set point in the billing lifecycle.
Show the hooks
export async function onCustomerCreate(params: {
organizationId: string
chargebeeCustomerId: string
displayName?: string | null
}): Promise<void> {
// Analytics, CRM sync, internal tenant linking
}
export async function onSubscriptionComplete(ctx: {
referenceId: string
subscriptionId: string
chargebeeSubscriptionId: string
status: string
}): Promise<void> {
// Enable SSO, flip feature flags, send onboarding email
}
/** Return false to deny billing actions for a reference. */
export async function onAuthorizeReference(_params: {
userId: string
organizationId: string
referenceId: string
action: 'create' | 'update' | 'cancel' | 'portal' | 'list'
}): Promise<boolean> {
return true
}
Testing
Run this check after you wire both webhook endpoints through a tunnel:
- Create an organization in Scalekit, or fire
organization.createdfrom the dashboard. - Confirm provisioning. A local
organizationrow exists, and Chargebee shows a customer with matchingorganizationIdmetadata. - Sign in as a user in that organization and open your billing page.
- Start checkout.
POST /api/subscription/createreturns{ mode: 'hosted', url }. Complete the payment with the test card4111 1111 1111 1111. - Confirm the redirect. The browser lands on
/billing?success=1, and the subscription appears without a manual refresh. - Replay a webhook. Send a test
subscription_activatedevent from Chargebee, and confirm the local row updates.
curl -s http://localhost:3000/api/session \
-H "Cookie: scalekit_session=<your-session-cookie>" | jq '.organizationId'
Troubleshooting
| Symptom | What to check |
|---|---|
| Organization exists but has no Chargebee customer | Scalekit webhook URL and events; SCALEKIT_WEBHOOK_SECRET; signature uses the raw body; handler logs |
| Webhooks ignored (Chargebee) | URL path; Basic Auth credentials match env; selected events; tunnel health |
| Checkout OK, UI still free tier | Was a future row created? Is pendingSubscriptionId on the customer? Eager sync route? Chargebee webhooks delivering? |
| Status out of date | chargebee_customer_id / chargebee_subscription_id populated? Event types subscribed? Handler returns 500 on DB failure so Chargebee retries? |
| Billing API returns 403 | referenceId must equal the session oid; don't key customers by email alone |
no_applicable_gateway on hosted checkout | Add a test gateway; set CHARGEBEE_GATEWAY_ACCOUNT_ID; enable Smart Routing |
| Checkout succeeds but no redirect | NEXT_PUBLIC_APP_URL must be in Chargebee Allowed redirect domains; declined cards prevent the redirect |
| Duplicate Chargebee customers | Race between the organization webhook and the first checkout; make createOrgCustomer idempotent |
Production notes
- Replace SQLite with Postgres or your production database. Keep the
reference_idindex. - Rotate webhook secrets independently for Scalekit and Chargebee, and store them in a secrets manager.
- Make handlers idempotent. Chargebee retries, so upsert by
chargebee_subscription_id. - Handle organization deletion. On
organization.deleted, cancel active Chargebee subscriptions and delete the local rows so billing doesn't continue. - Never expose Chargebee secret API keys in the browser. Only publishable keys belong in
NEXT_PUBLIC_variables.
Helpful prompts
Use these FAQ-style prompts with an AI coding agent (Cursor, Claude Code, Copilot CLI, Codex, and similar). First, set up your agent with the Scalekit CLI (see the next step). Then paste a prompt from a section below, and replace the bracketed placeholders with your stack.
1. Set up your coding agent
Run the Scalekit CLI setup so your agent loads Scalekit auth patterns. This reduces hallucinations on sessions, webhooks, and organizations:
npx @scalekit-inc/cli setup
For repeated use:
npm install -g @scalekit-inc/cli
scalekit setup
setup with no arguments launches an interactive wizard and detects installed tools. Target one agent explicitly if you prefer:
npx @scalekit-inc/cli setup cursor
npx @scalekit-inc/cli setup claude
npx @scalekit-inc/cli setup codex
npx @scalekit-inc/cli setup copilot
See the Scalekit CLI for flags and what gets installed. After setup, open your agent and use a prompt below.
2. Prompts (after setup)
How do I scaffold Scalekit webhooks and Chargebee customer provisioning?
Show the prompt
I'm integrating Chargebee with Scalekit organizations (org-mode billing). Generate:
1. Scalekit webhook route that verifies the raw body with verifyWebhookPayload
2. createOrgCustomer that upserts local org and creates a Chargebee customer with
meta_data.organizationId and customerType organization
3. authorizeReference requiring referenceId === session oid
My framework is [Next.js App Router / Express / Hono]. Database is [Drizzle / Prisma / Kysely].
How do I scaffold subscription create with a future row and hosted checkout?
Show the prompt
Write POST /api/subscription/create that:
- requireSession with oid
- authorizeReference for action create
- rejects if an active subscription exists for the org
- creates a local future subscription row
- stamps pendingSubscriptionId on the Chargebee customer metadata
- calls hostedPage.checkoutNewForItems with item price IDs
Return { mode: "hosted", url }.
How do I test Scalekit and Chargebee webhooks locally?
Show the prompt
Walk me through testing Scalekit and Chargebee webhooks locally with ngrok.
App runs on port 3000. Endpoints: /api/webhooks/scalekit and /api/webhooks/chargebee.
Include ngrok command, dashboard URLs to register, SCALEKIT_WEBHOOK_SECRET,
and CHARGEBEE_WEBHOOK_USERNAME/PASSWORD Basic Auth.
How do I generate a pricing page that starts checkout?
Show the prompt
Generate a React pricing page that calls POST /api/subscription/create with
itemPriceId, successUrl /billing?success=1, cancelUrl /billing.
Show loading while redirecting. If the API says an active subscription exists,
call POST /api/subscription/portal instead.
How do I implement a feature gate for an active plan?
Show the prompt
Using a local subscriptions table keyed by Scalekit organization ID, write
requireActivePlan(organizationId, itemPriceId) that returns true only for
active or in_trial rows. Show usage in a Next.js API route.
Why are my webhooks not reaching the server?
Show the prompt
My Scalekit or Chargebee webhooks are not reaching the server. Auth base paths
are /api/webhooks/scalekit and /api/webhooks/chargebee. Host is [Vercel / Railway / VPS].
Walk through DNS, routing, middleware, signature/Basic Auth, and env configuration.
Why is subscription status not updating after checkout or cancel?
Show the prompt
Subscription status in my database does not update after checkout or cancel.
Setup: Scalekit organizations + Chargebee, local future row + pendingSubscriptionId.
List likely causes and how to verify chargebee_customer_id and
chargebee_subscription_id are populated.
Resources
- saas-auth-chargebee-example — runnable reference app
- External IDs and metadata — map Scalekit organizations to internal tenant IDs
- Implement webhooks — webhook reference
- Chargebee documentation
- Chargebee API reference
We're always happy to help you with any questions you might have! Click here to reach out to us.