Skip to content

Commit 4af3da6

Browse files
Add cookbook: Sync B2B billing with Scalekit and Chargebee (#749)
* Add cookbook for Scalekit + Chargebee B2B billing Documents org-mode billing integration: Scalekit org webhooks to Chargebee customers, future subscription checkout, webhook sync, and links to the saas-auth-chargebee-example reference app. * fix(cookbook): correct outdated Scalekit SDK methods in Chargebee B2B billing cookbook - Replace verifyWebhookPayload + headers map with webhooks.verifySignature(rawBody, signature, secret) - Replace validateToken(claims) with validateAccessToken + decodeJwt for oid extraction - Use `scalekit` variable name (Node.js convention) - Matches patterns from implement-webhooks.mdx and implement-access-control.mdx - Verified: file prettier + pnpm build + links pass Addresses Code Rabbit review on #749.
1 parent 3210379 commit 4af3da6

1 file changed

Lines changed: 369 additions & 0 deletions

File tree

Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,369 @@
1+
---
2+
title: 'Sync B2B billing with Scalekit and Chargebee'
3+
description: 'Map Scalekit organizations to Chargebee customers, run hosted checkout, and keep subscription state in sync via webhooks.'
4+
date: 2026-06-15
5+
tags: ['Full stack auth', 'Webhooks', 'Next.js']
6+
sidebar:
7+
label: 'Chargebee B2B billing'
8+
tableOfContents: true
9+
excerpt: >
10+
B2B billing breaks when your auth org model and Chargebee customer records drift apart.
11+
This cookbook shows how to use the Scalekit organization ID as the billing reference,
12+
provision Chargebee customers on org creation, run hosted checkout, and reconcile
13+
subscription state from Chargebee webhooks.
14+
featured: false
15+
authors:
16+
- name: 'Saif'
17+
title: 'Developer Advocate'
18+
url: 'https://www.linkedin.com/in/saif-shines/'
19+
picture: '/images/blog/authors/saif.png'
20+
---
21+
22+
import { Aside, Tabs, TabItem } from '@astrojs/starlight/components';
23+
24+
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.
25+
26+
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.
27+
28+
<Aside type="note" title="Working example repo">
29+
The patterns below are implemented end-to-end in the [saas-auth-chargebee-example](https://github.com/scalekit-developers/saas-auth-chargebee-example) reference app (Next.js 14, Scalekit FSA, Chargebee SDK, SQLite). Clone it to follow along locally.
30+
</Aside>
31+
32+
## The problem
33+
34+
Shipping org-level billing on top of Scalekit auth surfaces four recurring failures:
35+
36+
- **Orphan organizations** — a customer signs up in Scalekit, but no Chargebee customer exists when they open your billing page.
37+
- **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.
38+
- **Checkout without local state** — hosted checkout succeeds, but your app still shows "no subscription" because nothing linked the Chargebee subscription back to the org.
39+
- **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.
40+
41+
## Who needs this
42+
43+
This cookbook is for you if:
44+
45+
- ✅ You run **Full Stack Auth** with organization support (`oid` in access tokens)
46+
- ✅ You bill **per organization**, not per individual user
47+
- ✅ You use Chargebee hosted checkout or the customer portal
48+
- ✅ You maintain a local subscription cache to gate features in your app
49+
50+
You **don't** need this if:
51+
52+
- ❌ You bill per user, not per organization
53+
- ❌ You use the [Chargebee Better Auth adapter](https://github.com/chargebee/js-framework-adapters/tree/main/packages/better-auth) and Better Auth's session model
54+
- ❌ Scalekit manages your entire product catalog and entitlements (no separate billing system)
55+
56+
## The solution
57+
58+
Treat the Scalekit **organization ID** as the single billing reference for the tenant. The integration has three seams:
59+
60+
1. **Provision on org create** — Scalekit `organization.created` webhook → create a Chargebee customer and store the mapping locally.
61+
2. **Future subscription before checkout** — create a local row with `status: future` before redirecting to Chargebee hosted checkout. Stamp `pendingSubscriptionId` on the Chargebee customer metadata so webhooks can match the right row.
62+
3. **Reconcile from Chargebee** — Chargebee subscription webhooks (and an eager sync on checkout redirect) update the local row to `active` or `in_trial`.
63+
64+
Authorization stays in your app: every billing API call checks that `referenceId === organizationId` from the session before calling Chargebee.
65+
66+
## Before you start
67+
68+
Gather these before you write code:
69+
70+
| Prerequisite | Where to get it |
71+
|--------------|-----------------|
72+
| Scalekit environment with organizations | [Scalekit dashboard](https://app.scalekit.com/) |
73+
| OAuth client (`skc_...`) + redirect URI | **API Keys** in the dashboard |
74+
| Chargebee sandbox site (Product Catalog 2.0) | Chargebee test site |
75+
| Plan item price ID (e.g. `growth-plan-monthly`) | Chargebee **Product Catalog** |
76+
| Test payment gateway (`gw_...`) | Chargebee **Payment Gateways** |
77+
| Public tunnel for webhooks | ngrok, LocalTunnel, or similar |
78+
79+
Environment variables (store in `.env`, never commit):
80+
81+
```bash title=".env.example"
82+
SCALEKIT_ENV_URL=https://your-env.scalekit.dev
83+
SCALEKIT_CLIENT_ID=skc_...
84+
SCALEKIT_CLIENT_SECRET=
85+
SCALEKIT_WEBHOOK_SECRET=
86+
CHARGEBEE_SITE=your-site-test
87+
CHARGEBEE_API_KEY=
88+
CHARGEBEE_PLAN_ITEM_PRICE_ID=growth-plan-monthly
89+
CHARGEBEE_GATEWAY_ACCOUNT_ID=gw_your_test_gateway_id
90+
CHARGEBEE_WEBHOOK_USERNAME=
91+
CHARGEBEE_WEBHOOK_PASSWORD=
92+
NEXT_PUBLIC_APP_URL=http://localhost:3000
93+
```
94+
95+
## Implementation
96+
97+
### 1. Store the org ↔ customer mapping
98+
99+
Add tables for organizations and subscriptions. The organization row holds the Chargebee customer ID; subscriptions are keyed by `reference_id` (the Scalekit org ID).
100+
101+
```sql title="db/schema.sql"
102+
CREATE TABLE organization (
103+
id TEXT PRIMARY KEY,
104+
display_name TEXT,
105+
chargebee_customer_id TEXT UNIQUE
106+
);
107+
108+
CREATE TABLE subscription (
109+
id TEXT PRIMARY KEY,
110+
reference_id TEXT NOT NULL,
111+
chargebee_customer_id TEXT NOT NULL,
112+
chargebee_subscription_id TEXT,
113+
status TEXT NOT NULL DEFAULT 'future',
114+
plan_id TEXT,
115+
seats INTEGER DEFAULT 1,
116+
trial_start INTEGER,
117+
trial_end INTEGER,
118+
current_period_end INTEGER,
119+
cancel_at_period_end INTEGER DEFAULT 0
120+
);
121+
```
122+
123+
Translate to your ORM. The `reference_id` column always stores the Scalekit organization ID from the `oid` claim.
124+
125+
### 2. Provision a Chargebee customer when Scalekit creates an org
126+
127+
Register a Scalekit webhook for `organization.created`, `organization.updated`, and `organization.deleted`. Verify the signature on the **raw request body** before parsing JSON.
128+
129+
<Aside type="caution" title="Verify webhook signatures">
130+
Never parse the body before verification. Re-serialized JSON breaks signature checks. Read `req.text()` (or the raw buffer), verify, then `JSON.parse`.
131+
</Aside>
132+
133+
```ts title="api/webhooks/scalekit/route.ts"
134+
import { NextRequest, NextResponse } from 'next/server';
135+
import { getScalekitClient } from '@/lib/scalekit';
136+
import { createOrgCustomer } from '@/lib/billing/create-org-customer';
137+
138+
export async function POST(req: NextRequest) {
139+
const rawBody = await req.text();
140+
const secret = process.env.SCALEKIT_WEBHOOK_SECRET!;
141+
const scalekit = getScalekitClient();
142+
143+
// Get the Scalekit signature header (do not use a full headers map for the new API)
144+
const signature = req.headers.get('scalekit-signature') ?? '';
145+
146+
// Verify webhook signature using the current SDK: scalekit.webhooks.verifySignature(rawBody, signature, secret)
147+
const isValid = await scalekit.webhooks.verifySignature(rawBody, signature, secret);
148+
if (!isValid) {
149+
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
150+
}
151+
152+
const event = JSON.parse(rawBody);
153+
154+
if (event.type === 'organization.created') {
155+
const organizationId = event.organization_id ?? event.data?.id;
156+
await createOrgCustomer({
157+
organizationId,
158+
displayName: event.data?.display_name ?? null,
159+
});
160+
}
161+
162+
return NextResponse.json({ received: true });
163+
}
164+
```
165+
166+
The `createOrgCustomer` helper upserts the local organization row, creates a Chargebee customer if one does not exist, and stores `organizationId` in Chargebee `meta_data`:
167+
168+
```ts title="lib/billing/create-org-customer.ts"
169+
const { customer } = await chargebee.customer.create({
170+
company: displayName ?? undefined,
171+
preferred_currency_code: 'USD',
172+
meta_data: {
173+
organizationId,
174+
customerType: 'organization',
175+
},
176+
});
177+
178+
await setChargebeeCustomerId(organizationId, customer.id);
179+
```
180+
181+
Return `200` after enqueueing work. Scalekit retries on non-2xx responses.
182+
183+
### 3. Read the organization ID from the session
184+
185+
Billing routes need the org context from the access token. Validate the token on every request and require the `oid` claim:
186+
187+
```ts title="lib/auth/require-session.ts"
188+
import { decodeJwt } from 'jose';
189+
190+
const isValid = await scalekit.validateAccessToken(accessToken);
191+
if (!isValid) {
192+
throw new SessionError(401, 'Invalid or expired token');
193+
}
194+
195+
// After successful validation, decode to read claims (e.g. `oid`, `sub`).
196+
// decodeJwt is safe here because validateAccessToken already performed
197+
// cryptographic signature validation + standard claim checks (exp, iss, aud).
198+
const claims = decodeJwt(accessToken);
199+
200+
const organizationId = claims.oid as string | undefined;
201+
if (!organizationId) {
202+
throw new SessionError(403, 'Organization context required for billing');
203+
}
204+
205+
return {
206+
userId: claims.sub as string,
207+
email: claims.email as string,
208+
organizationId,
209+
};
210+
```
211+
212+
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.
213+
214+
### 4. Authorize billing actions per organization
215+
216+
Before any Chargebee API call, confirm the caller's session org matches the billing reference:
217+
218+
```ts title="lib/auth/authorize-reference.ts"
219+
export async function authorizeReference({
220+
organizationId,
221+
referenceId,
222+
}: {
223+
organizationId: string;
224+
referenceId: string;
225+
}): Promise<boolean> {
226+
return referenceId === organizationId;
227+
}
228+
```
229+
230+
Extend this hook to deny billing for specific orgs (trial abuse, delinquent accounts) without changing Chargebee configuration.
231+
232+
### 5. Create a future subscription and start hosted checkout
233+
234+
When an org admin clicks **Subscribe**, create a local `future` row first, then call Chargebee `hostedPage.checkoutNewForItems`:
235+
236+
```ts title="api/subscription/create/route.ts"
237+
const referenceId = body.referenceId ?? ctx.organizationId;
238+
if (!(await authorizeReference({ organizationId: ctx.organizationId, referenceId }))) {
239+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
240+
}
241+
242+
const customerId = await getOrCreateCustomerId({ organizationId: referenceId });
243+
const localSub = await createFutureSubscription({
244+
referenceId,
245+
chargebeeCustomerId: customerId,
246+
});
247+
248+
await chargebee.customer.update(customerId, {
249+
meta_data: {
250+
pendingSubscriptionId: localSub.id,
251+
organizationId: referenceId,
252+
},
253+
});
254+
255+
const result = await chargebee.hostedPage.checkoutNewForItems({
256+
subscription_items: [{ item_price_id: planItemPriceId, quantity: seats }],
257+
customer: { id: customerId },
258+
redirect_url: `${appUrl}/api/subscription/success?callbackURL=/billing?success=1&subscriptionId=${localSub.id}`,
259+
cancel_url: `${appUrl}/billing`,
260+
});
261+
262+
return NextResponse.json({ mode: 'hosted', url: result.hosted_page.url });
263+
```
264+
265+
The `future` row gives your app a stable ID to reconcile against before Chargebee assigns a subscription ID.
266+
267+
### 6. Sync subscription state from Chargebee webhooks
268+
269+
Register a Chargebee webhook endpoint with HTTP Basic Auth. Handle at minimum:
270+
271+
| Chargebee event | Action |
272+
|-----------------|--------|
273+
| `subscription_created` | Link `chargebee_subscription_id`, set status |
274+
| `subscription_activated` / `subscription_started` | Mark `active` or `in_trial`, fire entitlements hook |
275+
| `subscription_changed` / `subscription_renewed` | Update plan, seats, period end |
276+
| `subscription_cancelled` | Mark cancelled, revoke entitlements |
277+
| `customer_deleted` | Clear local mapping |
278+
279+
Lookup order when matching a webhook to a local row:
280+
281+
1. `chargebee_subscription_id` on the local row
282+
2. `meta_data.subscriptionId` on the Chargebee subscription
283+
3. `meta_data.pendingSubscriptionId` on the Chargebee customer
284+
4. `future` row by `reference_id`
285+
286+
<Aside type="note" title="Chargebee retries on failure">
287+
Return `500` when your handler fails so Chargebee retries. Return `200` only after the database write succeeds. Scalekit org webhooks can return `200` immediately and process async — the failure modes differ.
288+
</Aside>
289+
290+
### 7. Eager-sync on checkout redirect
291+
292+
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:
293+
294+
```ts title="api/subscription/success/route.ts"
295+
export async function GET(request: NextRequest) {
296+
const subscriptionId = request.nextUrl.searchParams.get('subscriptionId');
297+
298+
if (subscriptionId) {
299+
const local = await findSubscriptionById(subscriptionId);
300+
if (local?.chargebeeSubscriptionId) {
301+
const result = await chargebee.subscription.retrieve(local.chargebeeSubscriptionId);
302+
await syncLocalFromChargebeeSubscription(local, result.subscription);
303+
}
304+
}
305+
306+
return NextResponse.redirect(new URL('/billing?success=1', request.url));
307+
}
308+
```
309+
310+
Webhooks remain the source of truth for ongoing changes. The redirect sync removes the "refresh and wait" gap after checkout.
311+
312+
### 8. Gate features from local subscription state
313+
314+
Read subscription status from your database, not from Chargebee on every request:
315+
316+
```ts title="api/subscription/list/route.ts"
317+
const subs = await findActiveByReferenceId(ctx.organizationId);
318+
return NextResponse.json({
319+
subscriptions: subs.map((sub) => ({
320+
id: sub.id,
321+
status: sub.status,
322+
planId: sub.planId,
323+
seats: sub.seats,
324+
trialEnd: sub.trialEnd,
325+
})),
326+
});
327+
```
328+
329+
Use `onSubscriptionComplete` and `onSubscriptionDeleted` hooks to flip feature flags, enable SSO, or send onboarding email when status changes.
330+
331+
## Testing
332+
333+
Run this five-minute validation script after wiring both webhook endpoints through a tunnel:
334+
335+
1. **Create an organization** in Scalekit (or fire `organization.created` via the dashboard).
336+
2. **Confirm provisioning** — local `organization` row exists and Chargebee dashboard shows a customer with matching `organizationId` metadata.
337+
3. **Sign in** as a user in that org and open your billing page.
338+
4. **Start checkout**`POST /api/subscription/create` returns `{ mode: 'hosted', url }`. Complete payment with test card `4111 1111 1111 1111`.
339+
5. **Confirm redirect** — browser lands on `/billing?success=1` and the subscription appears without a manual refresh.
340+
6. **Replay a webhook** — send a test `subscription_activated` event from the Chargebee dashboard and confirm the local row updates.
341+
342+
```bash title="Check session org context"
343+
curl -s http://localhost:3000/api/session \
344+
-H "Cookie: scalekit_session=<your-session-cookie>" | jq '.organizationId'
345+
```
346+
347+
## Common mistakes
348+
349+
- **`no_applicable_gateway` on hosted checkout** — Chargebee cannot select a payment gateway. Add a test gateway in the Chargebee dashboard, set `CHARGEBEE_GATEWAY_ACCOUNT_ID`, or enable Smart Routing.
350+
- **Checkout succeeds but no redirect**`NEXT_PUBLIC_APP_URL` must appear in Chargebee **Allowed redirect domains**. A declined test card also prevents redirect.
351+
- **Webhook signature failures** — reading `req.json()` before verification mutates the body. Use the raw body string for Scalekit; use Basic Auth for Chargebee.
352+
- **Duplicate Chargebee customers** — race between org webhook and first checkout click. Make `createOrgCustomer` idempotent: check the local mapping before calling `customer.create`.
353+
- **Billing API returns 403**`referenceId` in the request body does not match session `oid`. In org-mode v1, always pass the session organization ID.
354+
355+
## Production notes
356+
357+
- **Replace SQLite** with Postgres or your production database. Keep the `reference_id` index — webhook handlers query by org ID on every event.
358+
- **Rotate webhook secrets** independently for Scalekit and Chargebee. Store them in your secrets manager, not `.env` files in the image.
359+
- **Make handlers idempotent** — Chargebee retries webhooks; `subscription_activated` may arrive twice. Upsert by `chargebee_subscription_id`, do not insert blindly.
360+
- **Handle org deletion** — on `organization.deleted`, cancel active Chargebee subscriptions and delete local rows. Orphan subscriptions continue billing otherwise.
361+
- **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.
362+
363+
## Next steps
364+
365+
- Clone the [saas-auth-chargebee-example](https://github.com/scalekit-developers/saas-auth-chargebee-example) repo for a runnable Next.js implementation
366+
- [Enforce seat limits with SCIM provisioning](/cookbooks/scim-seat-limit-enforcement/) when billing plans cap user count
367+
- [External IDs and metadata](/guides/external-ids-and-metadata/) for mapping Scalekit orgs to your internal tenant IDs
368+
- [Organization webhook events](/reference/webhooks/organization-events/) for org lifecycle payloads
369+
- [Chargebee webhook documentation](https://www.chargebee.com/docs/2.0/events_and_webhooks.html) for the full event catalog

0 commit comments

Comments
 (0)