# How Kaneo Integrates Its Billing System with Creem: Architecture, Code, and Best Practices

> Discover how Kaneo integrates its billing system with Creem using a three-layer architecture. Learn about database storage, webhooks, and API synchronization for seamless subscription management.

- Repository: [kaneo.app/kaneo](https://github.com/usekaneo/kaneo)
- Tags: architecture
- Published: 2026-08-06

---

**Kaneo's billing system integrates with Creem through a three-layer architecture that wraps the Creem SDK, stores Creem identifiers in the `workspace_billing` database table, and synchronizes subscription state via webhooks and seat-update APIs.**

Kaneo is an open-source project management platform that uses **Creem** as its subscription management provider. The integration enables cloud-hosted Kaneo workspaces to handle plan upgrades, seat-based pricing, and self-service billing through Creem's infrastructure. This deep dive examines how the Kaneo billing system connects to Creem, with complete code references from the [usekaneo/kaneo](https://github.com/usekaneo/kaneo) repository.

## Architecture Overview: Three Layers of Creem Integration

Kaneo organizes its Creem integration into distinct layers that separate configuration concerns from business logic:

| Layer | Responsibility | Core Files |
|-------|---------------|------------|
| **Configuration** | Environment variables, feature flags, product ID mapping | [`apps/api/src/billing/config.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/billing/config.ts) |
| **Client Wrapper** | Typed SDK wrappers for checkout, seat updates, and portal links | [`apps/api/src/billing/creem-client.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/billing/creem-client.ts) |
| **Controllers / Webhooks** | Domain logic: database persistence, webhook handling, seat synchronization | `apps/api/src/billing/controllers/*.ts` |

This structure keeps Creem-specific details isolated while providing clean APIs for the rest of the application.

## Configuration Layer: Environment-Driven Setup

The [`config.ts`](https://github.com/usekaneo/kaneo/blob/main/config.ts) file in [`apps/api/src/billing/config.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/billing/config.ts) centralizes all Creem-related configuration and acts as the single source of truth for credentials and product mapping.

### Feature Flag and Mode Detection

```typescript
// apps/api/src/billing/config.ts
function isBillingEnabled(): boolean {
  return (
    process.env.KANEO_MODE === "cloud" &&
    !!process.env.CREEM_API_KEY &&
    !!process.env.CREEM_WEBHOOK_SECRET
  );
}

```

Billing is **only enabled in cloud mode** when both `CREEM_API_KEY` and `CREEM_WEBHOOK_SECRET` are defined. Self-hosted instances automatically bypass all billing code paths.

### Environment-Specific API URLs

```typescript
// apps/api/src/billing/config.ts
function creemApiBaseUrl(): string {
  return process.env.CREEM_TEST_MODE === "true"
    ? "https://test-api.creem.io/v1"
    : "https://api.creem.io/v1";
}

```

The `CREEM_TEST_MODE` environment variable switches between Creem's test and production environments without code changes.

### Product ID Mapping

Kaneo maps internal plan concepts to Creem product IDs through environment variables:

```typescript
// apps/api/src/billing/config.ts
function productIdFor(
  plan: "personal" | "team",
  interval: "monthly" | "annual"
): string | undefined {
  const key = `CREEM_PRODUCT_${plan.toUpperCase()}_${interval.toUpperCase()}`;
  return process.env[key];
}

```

This enables pricing changes without redeployment—simply update the environment variables (e.g., `CREEM_PRODUCT_TEAM_MONTHLY`, `CREEM_PRODUCT_TEAM_ANNUAL`).

## Creem Client Wrapper: SDK Abstraction

The [`creem-client.ts`](https://github.com/usekaneo/kaneo/blob/main/creem-client.ts) file in [`apps/api/src/billing/creem-client.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/billing/creem-client.ts) provides a thin, typed abstraction over the official Creem SDK. It instantiates a singleton-style client and exports three high-level operations.

### Client Initialization

```typescript
// apps/api/src/billing/creem-client.ts
import { Creem } from "creem";

function creemClient() {
  return new Creem({
    apiKey: creemApiKey(),
    server: process.env.CREEM_TEST_MODE === "true" ? "test" : "prod",
  });
}

```

### Core Operations

| Function | Purpose | Creem SDK Call |
|----------|---------|---------------|
| `createCheckoutSession` | Initialize checkout flow | `creemClient().checkouts.create()` |
| `updateSubscriptionSeats` | Adjust seat count with proration | `creemClient().subscriptions.update()` |
| `createCustomerPortalLink` | Generate self-service portal URL | `creemClient().customers.generateBillingLinks()` |

All helpers catch SDK errors and convert them to `HTTPException(502)` responses, standardizing error handling across the API.

### Creating a Checkout Session

```typescript
// apps/api/src/billing/creem-client.ts
interface CreateCheckoutParams {
  productId: string;
  units: number;
  successUrl: string;
  requestId: string;
  customerEmail?: string;
  metadata?: Record<string, string>;
}

async function createCheckoutSession(params: CreateCheckoutParams) {
  const checkout = await creemClient().checkouts.create({
    product: { id: params.productId },
    units: params.units,
    success_url: params.successUrl,
    metadata: {
      ...params.metadata,
      request_id: params.requestId,
    },
    ...(params.customerEmail && { customer_email: params.customerEmail }),
  });

  return {
    checkoutId: checkout.id,
    checkoutUrl: checkout.url,
  };
}

```

## Database Schema: Storing Creem Identifiers

The `workspace_billing` table in [`apps/api/src/database/schema.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/database/schema.ts) persists the link between Kaneo workspaces and Creem subscriptions:

```typescript
// apps/api/src/database/schema.ts
export const workspaceBillingTable = sqliteTable("workspace_billing", {
  workspaceId: text("workspace_id").notNull().primaryKey(),
  
  // Creem integration fields
  creemCustomerId: text("creem_customer_id"),
  creemSubscriptionId: text("creem_subscription_id").unique(),
  creemProductId: text("creem_product_id"),
  
  // Derived fields for querying
  plan: text("plan"), // "personal" | "team"
  interval: text("interval"), // "monthly" | "annual"
  seats: integer("seats").default(1),
  
  updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});

```

These columns enable Kaneo to query subscription status, update seat counts, and reconcile webhook events without calling Creem on every request.

## Checkout Flow: Upgrading Plans

The [`create-checkout.ts`](https://github.com/usekaneo/kaneo/blob/main/create-checkout.ts) controller in [`apps/api/src/billing/controllers/create-checkout.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/billing/controllers/create-checkout.ts) orchestrates the plan upgrade workflow:

```typescript
// apps/api/src/billing/controllers/create-checkout.ts
async function createCheckout(workspaceId: string, plan: string, interval: string) {
  // 1. Map to Creem product ID
  const productId = productIdFor(plan as any, interval as any);
  if (!productId) throw new HTTPException(400, { message: "Invalid plan" });

  // 2. Get workspace details
  const workspace = await getWorkspaceById(workspaceId);
  const memberCount = await getWorkspaceMemberCount(workspaceId);

  // 3. Create Creem checkout session
  const { checkoutUrl, checkoutId } = await createCheckoutSession({
    productId,
    units: memberCount,
    successUrl: `${process.env.KANEO_CLIENT_URL}/billing/success?checkout_id=${checkoutId}`,
    requestId: crypto.randomUUID(),
    customerEmail: workspace.ownerEmail,
    metadata: { workspaceId, plan, interval },
  });

  // 4. Store pending checkout reference
  await db.insert(pendingCheckoutsTable).values({
    checkoutId,
    workspaceId,
    productId,
    requestedAt: new Date(),
  });

  return { checkoutUrl };
}

```

The frontend redirects the user to `checkoutUrl`, where Creem handles payment collection. After success, Creem redirects to the `successUrl`, and the webhook handler (discussed below) finalizes the subscription record.

## Seat Synchronization: Per-Seat Billing

Kaneo uses seat-based pricing for team plans. The [`sync-seats.ts`](https://github.com/usekaneo/kaneo/blob/main/sync-seats.ts) controller in [`apps/api/src/billing/controllers/sync-seats.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/billing/controllers/sync-seats.ts) keeps Creem subscription quantities in sync with actual workspace membership:

```typescript
// apps/api/src/billing/controllers/sync-seats.ts
async function syncSeats(workspaceId: string, newSeatCount: number) {
  const billing = await db
    .select()
    .from(workspaceBillingTable)
    .where(eq(workspaceBillingTable.workspaceId, workspaceId))
    .get();

  if (!billing?.creemSubscriptionId || !billing?.creemProductId) {
    return; // Not a paid subscription
  }

  const result = await updateSubscriptionSeats({
    subscriptionId: billing.creemSubscriptionId,
    productId: billing.creemProductId,
    units: newSeatCount,
  });

  if (result.ok) {
    await db
      .update(workspaceBillingTable)
      .set({ seats: newSeatCount, updatedAt: new Date() })
      .where(eq(workspaceBillingTable.workspaceId, workspaceId));
  } else {
    // Log for retry/alerts—Creem charges prorated amounts automatically
    console.error("Failed to sync seats:", result.error);
  }
}

```

Creem applies **proration charges** automatically when `updateBehavior: "proration-charge"` is specified (the default in `updateSubscriptionSeats`), ensuring customers pay only for the time spent at each seat tier.

## Webhook Handling: Subscription State Reconciliation

The [`handle-webhook.ts`](https://github.com/usekaneo/kaneo/blob/main/handle-webhook.ts) controller in [`apps/api/src/billing/controllers/handle-webhook.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/billing/controllers/handle-webhook.ts) receives and processes Creem webhook events to maintain consistency.

### Signature Verification

```typescript
// apps/api/src/billing/controllers/handle-webhook.ts
import { constructWebhookEvent } from "creem/webhooks.js";

async function handleCreemWebhook(request: Request) {
  const payload = await request.text();
  const signature = request.headers.get("creem-signature") ?? "";

  let event;
  try {
    event = constructWebhookEvent({
      payload,
      secret: creemWebhookSecret(),
      signature,
    });
  } catch (err) {
    throw new HTTPException(400, { message: "Invalid signature" });
  }
  // ...
}

```

### Event Processing

```typescript
// apps/api/src/billing/controllers/handle-webhook.ts
switch (event.type) {
  case "subscription.created":
  case "subscription.updated": {
    const sub = event.data.object;
    
    await db
      .update(workspaceBillingTable)
      .set({
        creemCustomerId: sub.customer_id,
        creemSubscriptionId: sub.id,
        creemProductId: sub.items[0].product_id,
        plan: planForProductId(sub.items[0].product_id),
        interval: sub.items[0].interval,
        seats: sub.items[0].quantity,
        updatedAt: new Date(),
      })
      .where(
        eq(workspaceBillingTable.workspaceId, sub.metadata.workspace_id)
      );
    
    // Emit domain event for downstream listeners
    emit("billing.updated", { workspaceId: sub.metadata.workspace_id });
    break;
  }
  
  case "subscription.deleted": {
    await db
      .update(workspaceBillingTable)
      .set({
        plan: null,
        creemSubscriptionId: null,
        updatedAt: new Date(),
      })
      .where(
        eq(workspaceBillingTable.creemSubscriptionId, event.data.object.id)
      );
    break;
  }
}

```

Webhook handling ensures Kaneo's database stays synchronized with Creem's source of truth, even when changes originate from the customer portal or Creem's dashboard.

## Customer Portal: Self-Service Billing

The `createCustomerPortalLink` function enables users to manage their own subscriptions:

```typescript
// Usage in portal controller
import { createCustomerPortalLink } from "@/billing/creem-client";

async function getBillingPortalUrl(workspaceId: string) {
  const billing = await getWorkspaceBilling(workspaceId);
  if (!billing?.creemCustomerId) {
    throw new HTTPException(400, { message: "No active subscription" });
  }

  const { portalUrl } = await createCustomerPortalLink(
    billing.creemCustomerId
  );
  
  return { url: portalUrl };
}

```

The returned `portalUrl` points to Creem's hosted billing UI, where customers can update payment methods, view invoices, upgrade/downgrade plans, or cancel subscriptions—all without Kaneo building custom UI.

## Complete Integration Example

Here's how these pieces work together when a team workspace upgrades from personal to team:

1. **User clicks "Upgrade to Team"** → Frontend calls `POST /api/billing/checkout` with `plan=team&interval=monthly`

2. **Backend creates checkout** → [`create-checkout.ts`](https://github.com/usekaneo/kaneo/blob/main/create-checkout.ts) maps to `CREEM_PRODUCT_TEAM_MONTHLY`, calls `createCheckoutSession`, returns `checkoutUrl`

3. **User completes payment** on Creem-hosted page

4. **Creem sends `subscription.created` webhook** → [`handle-webhook.ts`](https://github.com/usekaneo/kaneo/blob/main/handle-webhook.ts) verifies signature, stores `creemSubscriptionId` and `creemCustomerId` in `workspace_billing`

5. **Team adds members** → Workspace service calls [`sync-seats.ts`](https://github.com/usekaneo/kaneo/blob/main/sync-seats.ts), which invokes `updateSubscriptionSeats` with new member count; Creem prorates the charge

6. **User clicks "Manage Billing"** → Backend calls `createCustomerPortalLink`, returns Creem portal URL

## Summary

- **Three-layer architecture**: Configuration ([`config.ts`](https://github.com/usekaneo/kaneo/blob/main/config.ts)), SDK wrapper ([`creem-client.ts`](https://github.com/usekaneo/kaneo/blob/main/creem-client.ts)), and domain controllers isolate Creem specifics from business logic
- **Database linkage**: `workspace_billing` table stores `creemCustomerId`, `creemSubscriptionId`, and `creemProductId` for persistent state
- **Checkout flow**: [`create-checkout.ts`](https://github.com/usekaneo/kaneo/blob/main/create-checkout.ts) initiates Creem checkouts with product ID mapping and metadata
- **Seat synchronization**: [`sync-seats.ts`](https://github.com/usekaneo/kaneo/blob/main/sync-seats.ts) calls `updateSubscriptionSeats` with proration when workspace membership changes
- **Webhook reconciliation**: [`handle-webhook.ts`](https://github.com/usekaneo/kaneo/blob/main/handle-webhook.ts) verifies signatures and updates database on subscription events
- **Self-service portal**: `createCustomerPortalLink` generates URLs to Creem's hosted billing management UI

## Frequently Asked Questions

### What Creem SDK version does Kaneo use?

Kaneo uses the official `creem` npm package with its native TypeScript bindings. The wrapper in [`creem-client.ts`](https://github.com/usekaneo/kaneo/blob/main/creem-client.ts) imports `Creem` from `"creem"` and `constructWebhookEvent` from `"creem/webhooks.js"`. Check the repository's [`package.json`](https://github.com/usekaneo/kaneo/blob/main/package.json) for the exact version pinned in `apps/api/`.

### Can self-hosted Kaneo instances enable billing?

No. The `isBillingEnabled()` function in [`config.ts`](https://github.com/usekaneo/kaneo/blob/main/config.ts) explicitly requires `process.env.KANEO_MODE === "cloud"`. Self-hosted deployments lack this environment variable by default, effectively disabling all billing code paths. This design separates the open-source core from commercial cloud features.

### How does Kaneo handle failed Creem API calls?

The [`creem-client.ts`](https://github.com/usekaneo/kaneo/blob/main/creem-client.ts) wrapper catches SDK errors and throws `HTTPException(502)` with descriptive messages. For seat synchronization failures in [`sync-seats.ts`](https://github.com/usekaneo/kaneo/blob/main/sync-seats.ts), errors are logged but not thrown, allowing workspace operations to continue while alerting operators for manual reconciliation. The webhook handler returns 4xx status codes for signature failures and 2xx only after successful database updates.

### What happens when a subscription is cancelled via the Creem portal?

Creem sends a `subscription.deleted` webhook event. The [`handle-webhook.ts`](https://github.com/usekaneo/kaneo/blob/main/handle-webhook.ts) controller clears `creemSubscriptionId` and `plan` from the `workspace_billing` row but preserves `creemCustomerId` for potential reactivation. The workspace downgrades to free-tier limits immediately, though existing data remains accessible.