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.organizationId → organization.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:
organizationIdas a foreign key to the organization tableisPaying,paidPlanId, andpaidPlanStatusflags- 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:
-
Authentication Layer: The
src/middleware/ensureUser.tsmiddleware extractssession.activeOrganizationIdand builds theEnsuredUserContextcontaining the resolvedorganizationId. -
Authorization Gateway: Server-side functions (such as
rank-tracking,ai-search, andprojectAPIs) receivecontext: EnsuredUserContextas their first parameter. These functions immediately validate the organization and invokecustomerHasPaidPlan(context.organizationId)to gate paid features【/cache/repos/github.com/every-app/open-seo/main/src/serverFunctions/rank-tracking.ts#L129-L200】. -
MCP Tool Execution: Model-Context-Protocol (MCP) tools embed the organization ID in
McpToolAuthContext. The helperbuildBillingCustomerconstructs aBillingCustomerContextfrom 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.idas the root tenant identifier, stored in user sessions and propagated throughEnsuredUserContextto all server functions. - Billing Integration: The Autumn billing service treats
organizationIdas 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
organizationIdforeign keys, and queries always filter by this identifier to prevent cross-tenant access. - Feature Gating: The
customerHasPaidPlanfunction insrc/server/billing/subscription.tsenforces subscription tiers, while MCP tools usebuildBillingCustomerto validate credit availability. - Self-Hosted Compatibility: The same schema and billing code paths execute in self-hosted mode, with the
isHostedflag 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →