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

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 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
Client Wrapper Typed SDK wrappers for checkout, seat updates, and portal links 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 file in 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

// 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

// 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:

// 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 file in 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

// 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

// 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 persists the link between Kaneo workspaces and Creem subscriptions:

// 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 controller in apps/api/src/billing/controllers/create-checkout.ts orchestrates the plan upgrade workflow:

// 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 controller in apps/api/src/billing/controllers/sync-seats.ts keeps Creem subscription quantities in sync with actual workspace membership:

// 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 controller in apps/api/src/billing/controllers/handle-webhook.ts receives and processes Creem webhook events to maintain consistency.

Signature Verification

// 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

// 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:

// 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 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 verifies signature, stores creemSubscriptionId and creemCustomerId in workspace_billing

  5. Team adds members → Workspace service calls 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), SDK wrapper (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 initiates Creem checkouts with product ID mapping and metadata
  • Seat synchronization: sync-seats.ts calls updateSubscriptionSeats with proration when workspace membership changes
  • Webhook reconciliation: 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 imports Creem from "creem" and constructWebhookEvent from "creem/webhooks.js". Check the repository's package.json for the exact version pinned in apps/api/.

Can self-hosted Kaneo instances enable billing?

No. The isBillingEnabled() function in 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 wrapper catches SDK errors and throws HTTPException(502) with descriptive messages. For seat synchronization failures in 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 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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →