Build an Astrology SaaS

Multi-tenant platform with white-label support

Overview

Build a complete multi-tenant astrology SaaS platform that allows your customers to offer astrology services under their own brand. This guide covers authentication, billing, usage tracking, and white-label customization.

Multi-Tenant

Isolated customer data

White-Label

Custom branding

Usage Billing

Pay per API call

Architecture

┌─────────────────────────────────────────────────────────────┐
│                     Your SaaS Platform                       │
├─────────────────────────────────────────────────────────────┤
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐    │
│  │ Tenant A │  │ Tenant B │  │ Tenant C │  │ Tenant D │    │
│  │ (Brand X)│  │ (Brand Y)│  │ (Brand Z)│  │ (Brand W)│    │
│  └────┬─────┘  └────┬─────┘  └────┬─────┘  └────┬─────┘    │
│       │             │             │             │           │
│       └─────────────┴──────┬──────┴─────────────┘           │
│                            │                                 │
│                    ┌───────▼───────┐                        │
│                    │  API Gateway  │                        │
│                    │  (Your Code)  │                        │
│                    └───────┬───────┘                        │
└────────────────────────────┼────────────────────────────────┘
                             │
                     ┌───────▼───────┐
                     │  Vedika API   │
                     │  (Upstream)   │
                     └───────────────┘
                            

Database Schema

-- Tenants (your customers)
CREATE TABLE tenants (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    subdomain VARCHAR(63) UNIQUE,
    custom_domain VARCHAR(255) UNIQUE,
    api_key VARCHAR(64) UNIQUE NOT NULL,

    -- Branding
    logo_url TEXT,
    primary_color VARCHAR(7) DEFAULT '#6366f1',
    company_name VARCHAR(255),

    -- Billing
    plan VARCHAR(20) DEFAULT 'starter',
    credits_balance DECIMAL(12,2) DEFAULT 0,
    stripe_customer_id VARCHAR(255),

    -- Limits
    monthly_api_limit INTEGER DEFAULT 10000,
    rate_limit_per_minute INTEGER DEFAULT 60,

    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Tenant Users
CREATE TABLE tenant_users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
    email VARCHAR(255) NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    role VARCHAR(20) DEFAULT 'user',
    created_at TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE(tenant_id, email)
);

-- API Usage Tracking
CREATE TABLE api_usage (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
    endpoint VARCHAR(255) NOT NULL,
    credits_used DECIMAL(8,4) NOT NULL,
    response_time_ms INTEGER,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Create indexes
CREATE INDEX idx_api_usage_tenant_date ON api_usage(tenant_id, created_at);
CREATE INDEX idx_tenants_subdomain ON tenants(subdomain);
CREATE INDEX idx_tenants_custom_domain ON tenants(custom_domain);

Tenant Resolution Middleware

// middleware/tenant.ts
import { Request, Response, NextFunction } from 'express';
import { db } from '../db';

interface Tenant {
  id: string;
  name: string;
  apiKey: string;
  plan: string;
  creditsBalance: number;
  rateLimitPerMinute: number;
  branding: {
    logoUrl: string;
    primaryColor: string;
    companyName: string;
  };
}

declare global {
  namespace Express {
    interface Request {
      tenant?: Tenant;
    }
  }
}

export async function resolveTenant(req: Request, res: Response, next: NextFunction) {
  // Try API key first (for API requests)
  const apiKey = req.headers['x-api-key'] as string;
  if (apiKey) {
    const tenant = await db.query(
      'SELECT * FROM tenants WHERE api_key = $1',
      [apiKey]
    );
    if (tenant.rows[0]) {
      req.tenant = formatTenant(tenant.rows[0]);
      return next();
    }
  }

  // Try subdomain
  const host = req.hostname;
  const subdomain = host.split('.')[0];

  if (subdomain && subdomain !== 'www' && subdomain !== 'api') {
    const tenant = await db.query(
      'SELECT * FROM tenants WHERE subdomain = $1 OR custom_domain = $2',
      [subdomain, host]
    );
    if (tenant.rows[0]) {
      req.tenant = formatTenant(tenant.rows[0]);
      return next();
    }
  }

  res.status(404).json({ error: 'Tenant not found' });
}

function formatTenant(row: any): Tenant {
  return {
    id: row.id,
    name: row.name,
    apiKey: row.api_key,
    plan: row.plan,
    creditsBalance: parseFloat(row.credits_balance),
    rateLimitPerMinute: row.rate_limit_per_minute,
    branding: {
      logoUrl: row.logo_url,
      primaryColor: row.primary_color,
      companyName: row.company_name
    }
  };
}

Vedika API Proxy

// services/vedikaProxy.ts
import { VedikaClient } from '@anthropic/vedika-sdk';

const vedika = new VedikaClient({
  apiKey: process.env.VEDIKA_API_KEY // Your master API key
});

// Pricing per endpoint (in credits)
const ENDPOINT_PRICING: Record = {
  'birth-chart': 0.10,
  'kundli-match': 0.15,
  'panchang': 0.05,
  'dasha': 0.08,
  'dosha': 0.08,
  'daily-horoscope': 0.02,
  'query': 0.25 // AI queries
};

export async function proxyToVedika(
  tenantId: string,
  endpoint: string,
  params: any
): Promise<{ data: any; creditsUsed: number }> {
  const startTime = Date.now();

  // Check tenant credits
  const tenant = await db.query(
    'SELECT credits_balance FROM tenants WHERE id = $1',
    [tenantId]
  );

  const creditsNeeded = ENDPOINT_PRICING[endpoint] || 0.10;

  if (tenant.rows[0].credits_balance < creditsNeeded) {
    throw new Error('Insufficient credits');
  }

  // Call Vedika API
  let data: any;
  switch (endpoint) {
    case 'birth-chart':
      data = await vedika.birthChart(params);
      break;
    case 'kundli-match':
      data = await vedika.kundliMatch(params);
      break;
    case 'panchang':
      data = await vedika.panchang(params);
      break;
    case 'dasha':
      data = await vedika.dasha(params);
      break;
    case 'dosha':
      data = await vedika.dosha(params);
      break;
    default:
      throw new Error(`Unknown endpoint: ${endpoint}`);
  }

  const responseTime = Date.now() - startTime;

  // Deduct credits and log usage
  await db.query(`
    UPDATE tenants SET credits_balance = credits_balance - $1 WHERE id = $2;
    INSERT INTO api_usage (tenant_id, endpoint, credits_used, response_time_ms)
    VALUES ($2, $3, $1, $4);
  `, [creditsNeeded, tenantId, endpoint, responseTime]);

  return { data, creditsUsed: creditsNeeded };
}

Per-Tenant Rate Limiting

// middleware/rateLimiter.ts
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);

export async function tenantRateLimiter(req: Request, res: Response, next: NextFunction) {
  if (!req.tenant) return next();

  const key = `ratelimit:${req.tenant.id}`;
  const limit = req.tenant.rateLimitPerMinute;
  const window = 60; // 1 minute

  const current = await redis.incr(key);

  if (current === 1) {
    await redis.expire(key, window);
  }

  // Set rate limit headers
  res.setHeader('X-RateLimit-Limit', limit);
  res.setHeader('X-RateLimit-Remaining', Math.max(0, limit - current));

  if (current > limit) {
    const ttl = await redis.ttl(key);
    res.setHeader('X-RateLimit-Reset', Math.floor(Date.now() / 1000) + ttl);

    return res.status(429).json({
      error: 'Rate limit exceeded',
      retryAfter: ttl
    });
  }

  next();
}

Stripe Billing Integration

// routes/billing.ts
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

// Credit packages
const CREDIT_PACKAGES = [
  { id: 'credits_1000', amount: 1000, price: 999 },   // $9.99
  { id: 'credits_5000', amount: 5000, price: 3999 },  // $39.99
  { id: 'credits_20000', amount: 20000, price: 14999 } // $149.99
];

router.post('/purchase-credits', async (req, res) => {
  const { packageId } = req.body;
  const pkg = CREDIT_PACKAGES.find(p => p.id === packageId);

  if (!pkg) {
    return res.status(400).json({ error: 'Invalid package' });
  }

  const session = await stripe.checkout.sessions.create({
    customer: req.tenant.stripeCustomerId,
    payment_method_types: ['card'],
    line_items: [{
      price_data: {
        currency: 'usd',
        product_data: {
          name: `${pkg.amount} API Credits`,
          description: 'Vedika API credits for astrology endpoints'
        },
        unit_amount: pkg.price
      },
      quantity: 1
    }],
    mode: 'payment',
    success_url: `${process.env.APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.APP_URL}/billing/cancelled`,
    metadata: {
      tenant_id: req.tenant.id,
      credits: pkg.amount.toString()
    }
  });

  res.json({ checkoutUrl: session.url });
});

// Webhook handler
router.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  const sig = req.headers['stripe-signature'];
  const event = stripe.webhooks.constructEvent(
    req.body,
    sig,
    process.env.STRIPE_WEBHOOK_SECRET
  );

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object;
    const { tenant_id, credits } = session.metadata;

    await db.query(
      'UPDATE tenants SET credits_balance = credits_balance + $1 WHERE id = $2',
      [parseInt(credits), tenant_id]
    );
  }

  res.json({ received: true });
});

White-Label Configuration

// routes/branding.ts
router.get('/branding', async (req, res) => {
  res.json({
    logoUrl: req.tenant.branding.logoUrl || '/default-logo.png',
    primaryColor: req.tenant.branding.primaryColor,
    companyName: req.tenant.branding.companyName || req.tenant.name,
    features: getPlanFeatures(req.tenant.plan)
  });
});

router.put('/branding', async (req, res) => {
  const { logoUrl, primaryColor, companyName } = req.body;

  await db.query(`
    UPDATE tenants
    SET logo_url = $1, primary_color = $2, company_name = $3, updated_at = NOW()
    WHERE id = $4
  `, [logoUrl, primaryColor, companyName, req.tenant.id]);

  res.json({ success: true });
});

function getPlanFeatures(plan: string) {
  const features = {
    starter: {
      endpoints: ['birth-chart', 'panchang', 'daily-horoscope'],
      customDomain: false,
      whiteLabel: false,
      support: 'email'
    },
    professional: {
      endpoints: ['birth-chart', 'panchang', 'dasha', 'dosha', 'kundli-match', 'daily-horoscope'],
      customDomain: true,
      whiteLabel: true,
      support: 'priority'
    },
    enterprise: {
      endpoints: 'all',
      customDomain: true,
      whiteLabel: true,
      support: 'dedicated'
    }
  };
  return features[plan] || features.starter;
}

Usage Analytics Dashboard

// routes/analytics.ts
router.get('/usage', async (req, res) => {
  const { startDate, endDate } = req.query;

  // Daily usage breakdown
  const dailyUsage = await db.query(`
    SELECT
      DATE(created_at) as date,
      endpoint,
      COUNT(*) as calls,
      SUM(credits_used) as credits
    FROM api_usage
    WHERE tenant_id = $1
      AND created_at >= $2
      AND created_at <= $3
    GROUP BY DATE(created_at), endpoint
    ORDER BY date DESC
  `, [req.tenant.id, startDate, endDate]);

  // Summary stats
  const summary = await db.query(`
    SELECT
      COUNT(*) as total_calls,
      SUM(credits_used) as total_credits,
      AVG(response_time_ms) as avg_response_time
    FROM api_usage
    WHERE tenant_id = $1
      AND created_at >= $2
      AND created_at <= $3
  `, [req.tenant.id, startDate, endDate]);

  res.json({
    summary: summary.rows[0],
    dailyBreakdown: dailyUsage.rows,
    currentBalance: req.tenant.creditsBalance
  });
});

Dynamic Theme (React)

// contexts/TenantContext.tsx
import { createContext, useContext, useEffect, useState } from 'react';

interface TenantBranding {
  logoUrl: string;
  primaryColor: string;
  companyName: string;
}

const TenantContext = createContext<TenantBranding | null>(null);

export function TenantProvider({ children }) {
  const [branding, setBranding] = useState<TenantBranding | null>(null);

  useEffect(() => {
    fetch('/api/branding')
      .then(res => res.json())
      .then(data => {
        setBranding(data);
        // Apply theme
        document.documentElement.style.setProperty('--primary-color', data.primaryColor);
      });
  }, []);

  if (!branding) return <div>Loading...</div>;

  return (
    <TenantContext.Provider value={branding}>
      {children}
    </TenantContext.Provider>
  );
}

export function useTenant() {
  return useContext(TenantContext);
}

// Usage in components
function Header() {
  const tenant = useTenant();

  return (
    <header className="bg-primary">
      <img src={tenant.logoUrl} alt={tenant.companyName} />
      <h1>{tenant.companyName}</h1>
    </header>
  );
}

Deployment Checklist

Database

PostgreSQL with connection pooling (PgBouncer/Neon)

Redis

For rate limiting and session storage

Wildcard SSL

*.yourdomain.com for subdomain tenants

CDN

Cloudflare for custom domains and caching

Next Steps