How the Multi-Tenant Organization System Works with Org-Specific Billing in Open SEO

Open SEO binds every user session, data record, and billing transaction to a unique organization ID, using the Autumn billing service with Cloudflare KV caching to enforce per-organization subscription tiers across all server functions and MCP tools.

Open SEO is architected as a multi-tenant SaaS platform where each logical customer exists as a distinct organization entity. The system isolates data, permissions, and billing through a consistent organization identifier that propagates from the database schema through to third-party billing integrations. Understanding how the multi-tenant organization system works with org-specific billing is essential for developers extending the platform or deploying self-hosted instances.

Organization Model and Database Schema

The foundation of Open SEO's multi-tenancy resides in src/db/better-auth-schema.ts, where the organization table defines the core tenant boundary【/cache/repos/github.com/every-app/open-seo/main/src/db/better-auth-schema.ts#L11-L21】.

Each user can belong to multiple organizations through the member table, which maintains a foreign key relationship (member.organizationIdorganization.id). The session record stores the current working context in session.activeOrganizationId, ensuring every request operates within a specific organizational scope.

When a request enters the system, the ensureUser.ts middleware resolves the session and constructs an EnsuredUserContext object that always carries the organizationId derived from the active session. This context object becomes the authoritative tenant identifier for all downstream operations.

Billing Architecture and Autumn Integration

Per-organization billing data lives in src/db/billing.schema.ts, specifically the billing_customer_status table【/cache/repos/github.com/every-app/open-seo/main/src/db/billing.schema.ts#L5-L21】. This table maintains:

  • organizationId as a foreign key to the organization table
  • isPaying, paidPlanId, and paidPlanStatus flags
  • A cached JSON payload from the Autumn billing service

The billing service implementation in src/server/billing/subscription.ts treats the organization ID as the canonical customer identifier in the Autumn API:

// get or create the Autumn customer for the organization
const customer = await autumn.customers.getOrCreate({
  customerId: context.organizationId,
  email: context.userEmail,
});

The customerHasPaidPlan(orgId) function checks the Autumn feature flag AUTUMN_PAID_PLAN_FEATURE_ID to determine subscription status【/cache/repos/github.com/every-app/open-seo/main/src/server/billing/subscription.ts#L66-L73】. To prevent API throttling, billing status is cached in Cloudflare KV under the key autumn:customer-ensured:<orgId> with a 24-hour TTL【/cache/repos/github.com/every-app/open-seo/main/src/server/billing/subscription.ts#L30-L38】.

Request Flow and Context Propagation

Every authenticated request flows through three distinct phases:

  1. Authentication Layer: The src/middleware/ensureUser.ts middleware extracts session.activeOrganizationId and builds the EnsuredUserContext containing the resolved organizationId.

  2. Authorization Gateway: Server-side functions (such as rank-tracking, ai-search, and project APIs) receive context: EnsuredUserContext as their first parameter. These functions immediately validate the organization and invoke customerHasPaidPlan(context.organizationId) to gate paid features【/cache/repos/github.com/every-app/open-seo/main/src/serverFunctions/rank-tracking.ts#L129-L200】.

  3. MCP Tool Execution: Model-Context-Protocol (MCP) tools embed the organization ID in McpToolAuthContext. The helper buildBillingCustomer constructs a BillingCustomerContext from this auth data, enabling billing checks within tool execution【/cache/repos/github.com/every-app/open-seo/main/src/server/mcp/context.ts#L19-L29】.

Multi-Tenant Isolation Guarantees

Open SEO enforces strict tenant isolation across four critical dimensions:

Data Isolation: All organization-specific tables—including billing_customer_status, project, gsc, and reddit_attribution—include organizationId foreign keys. Queries always filter by this identifier, such as ProjectService.listProjects(context.organizationId).

Permission Enforcement: The member.role field determines user capabilities. Before any mutation, the server verifies that member.organizationId matches the context.organizationId.

Billing Isolation: The Autumn API receives the organization ID as the customer identifier, ensuring each tenant maintains an independent credit balance and subscription plan. Billing checks never execute globally across organizations.

Cache Safety: KV cache entries are namespaced by organization (autumn:customer-ensured:<orgId>), preventing cross-tenant cache leakage.

Self-Hosted Mode Behavior

When deployed in self-hosted environments, Open SEO uses the identical schema and code paths but disables paid-plan enforcement. The isHosted flag, when set to false, causes functions like rank-tracking to skip customerHasPaidPlan validation while maintaining the same organization scoping logic【/cache/repos/github.com/every-app/open-seo/main/src/serverFunctions/rank-tracking.ts#L129-L200】. This ensures billing infrastructure remains intact for future migration to hosted mode without schema changes.

Practical Implementation Examples

Creating a New Organization

This example demonstrates inserting an organization and establishing the owner relationship:

import { db } from "@/db";
import { organization } from "@/db/better-auth-schema";

export async function createOrganization(name: string, slug: string, userId: string) {
  // Insert organization
  const org = await db
    .insert(organization)
    .values({ name, slug })
    .returning()
    .get();

  // Add the creator as a member (owner role could be stored elsewhere)
  await db.insert(member).values({
    organizationId: org.id,
    userId,
    role: "owner",
  });

  return org;
}

Source: organization table definition【/cache/repos/github.com/every-app/open-seo/main/src/db/better-auth-schema.ts#L11-L21】.

Validating Paid Plan Access

Gate feature execution using the billing subscription checker:

import { customerHasPaidPlan } from "@/server/billing/subscription";

export async function canRunPaidTool(context: EnsuredUserContext) {
  const hasPlan = await customerHasPaidPlan(context.organizationId);
  if (!hasPlan) throw new Error("Paid plan required");
  // …run the paid-feature
}

Source: customerHasPaidPlan implementation【/cache/repos/github.com/every-app/open-seo/main/src/server/billing/subscription.ts#L66-L73】.

Charging Credits in MCP Tools

Manage usage credits within Model-Context-Protocol tools:

import { buildBillingCustomer, assertUsageCreditsAvailable } from "@/server/billing/subscription";
import { requireMcpToolAuthContext } from "@/server/mcp/context";

export async function runDataforSEO(extra: ToolExtra, projectId: string) {
  const auth = requireMcpToolAuthContext(extra);
  const billingCtx = buildBillingCustomer(auth, projectId);

  // Ensure the org still has credits
  await assertUsageCreditsAvailable(billingCtx.organizationId);

  // Proceed with DataforSEO call…
}

Sources: requireMcpToolAuthContext【/cache/repos/github.com/every-app/open-seo/main/src/server/mcp/context.ts#L100-L108】, buildBillingCustomer【/cache/repos/github.com/every-app/open-seo/main/src/server/mcp/context.ts#L19-L29】, assertUsageCreditsAvailable【/cache/repos/github.com/every-app/open-seo/main/src/server/billing/subscription.ts#L82-L93】.

Listing Organization-Scoped Projects

Retrieve projects filtered by the authenticated organization:

import { ProjectService } from "@/server/services/project";
import { requireMcpToolAuthContext } from "@/server/mcp/context";

export async function listProjects(extra: ToolExtra) {
  const auth = requireMcpToolAuthContext(extra);
  const projects = await ProjectService.listProjects(auth.organizationId);
  return projects;
}

Source: list_projects tool implementation【/cache/repos/github.com/every-app/open-seo/main/src/server/mcp/tools/list-projects.ts#L41-L52】.

Summary

  • Tenant Identification: Open SEO uses organization.id as the root tenant identifier, stored in user sessions and propagated through EnsuredUserContext to all server functions.
  • Billing Integration: The Autumn billing service treats organizationId as the customer ID, with payment status cached in Cloudflare KV for 24 hours to optimize API usage.
  • Data Isolation: All tables containing organization-specific data include organizationId foreign keys, and queries always filter by this identifier to prevent cross-tenant access.
  • Feature Gating: The customerHasPaidPlan function in src/server/billing/subscription.ts enforces subscription tiers, while MCP tools use buildBillingCustomer to validate credit availability.
  • Self-Hosted Compatibility: The same schema and billing code paths execute in self-hosted mode, with the isHosted flag disabling payment checks while preserving organizational scoping.

Frequently Asked Questions

How does Open SEO handle users who belong to multiple organizations?

Open SEO supports multi-organization membership through the member table, which maintains many-to-many relationships between users and organizations. The session.activeOrganizationId field determines which organization context is currently active, and the EnsuredUserContext object carries this ID through the request lifecycle. Users switch contexts by updating their active organization in the session, which changes the organizationId available to all server functions without altering the underlying membership records.

What prevents one organization from accessing another organization's data?

Data isolation relies on foreign key relationships and mandatory query filters. Every organization-specific table includes an organizationId column, and the application layer enforces that all queries—whether in ProjectService, billing checks, or MCP tools—filter by the organizationId extracted from the authenticated session. The middleware in src/middleware/ensureUser.ts validates that the user has membership in the claimed organization before constructing the context object, preventing unauthorized cross-tenant access.

How does the billing system handle organization-specific credit balances?

The Autumn billing service uses the organizationId as the unique customer identifier via autumn.customers.getOrCreate(). This ensures each organization maintains an independent subscription status and credit balance. The customerHasPaidPlan function checks this external service while caching results in Cloudflare KV under org-specific keys (autumn:customer-ensured:<orgId>), ensuring billing isolation and reducing API latency for subsequent requests within the 24-hour cache window.

Can the multi-tenant system run without the Autumn billing service?

Yes. In self-hosted deployments, Open SEO sets the isHosted flag to false, which bypasses the customerHasPaidPlan checks in features like rank-tracking while preserving all organization scoping logic. The same database schema and context propagation mechanisms remain active, allowing seamless migration to hosted mode with billing enabled later. The billing tables and KV caching infrastructure remain in place, ensuring no schema migrations are required when transitioning between modes.

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 →