xml-pipeline/docs/bloxserver-billing.md
dullfig a5c00c1e90 Add BloxServer API scaffold + architecture docs
BloxServer API (FastAPI + SQLAlchemy async):
- Database models: users, flows, triggers, executions, usage tracking
- Clerk JWT auth with dev mode bypass for local testing
- SQLite support for local dev, PostgreSQL for production
- CRUD routes for flows, triggers, executions
- Public webhook endpoint with token auth
- Health/readiness endpoints
- Pydantic schemas with camelCase aliases for frontend
- Docker + docker-compose setup

Architecture documentation:
- Librarian architecture with RLM-powered query engine
- Stripe billing integration (usage-based, trials, webhooks)
- LLM abstraction layer (rate limiting, semantic cache, failover)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 22:04:25 -08:00

23 KiB

BloxServer Billing Integration — Stripe

Status: Design Date: January 2026

Overview

BloxServer uses Stripe for subscription management, usage-based billing, and payment processing. This document specifies the integration architecture, webhook handlers, and usage tracking system.

Pricing Tiers

Tier Price Runs/Month Features
Free $0 1,000 1 workflow, built-in tools, community support
Pro $29 100,000 Unlimited workflows, marketplace, WASM, project memory, priority support
Enterprise Custom Unlimited SSO/SAML, SLA, dedicated support, private marketplace

Overage Pricing (Pro)

Metric Included Overage Rate
Workflow runs 100K/mo $0.50 per 1K
Storage 10 GB $0.10 per GB
WASM execution 1000 CPU-sec $0.01 per CPU-sec

Stripe Product Structure

Products:
├── bloxserver_free
│   └── price_free_monthly ($0/month, metered runs)
├── bloxserver_pro
│   ├── price_pro_monthly ($29/month base)
│   ├── price_pro_runs_overage (metered, $0.50/1K)
│   └── price_pro_storage_overage (metered, $0.10/GB)
└── bloxserver_enterprise
    └── price_enterprise_custom (quoted per customer)

Stripe Configuration

# One-time setup (or via Stripe Dashboard)

# Free tier product
free_product = stripe.Product.create(
    name="BloxServer Free",
    description="Build AI agent swarms, visually",
)

free_price = stripe.Price.create(
    product=free_product.id,
    unit_amount=0,
    currency="usd",
    recurring={"interval": "month"},
    metadata={"tier": "free", "runs_included": "1000"}
)

# Pro tier product
pro_product = stripe.Product.create(
    name="BloxServer Pro",
    description="Unlimited workflows, marketplace access, custom WASM",
)

pro_base_price = stripe.Price.create(
    product=pro_product.id,
    unit_amount=2900,  # $29.00
    currency="usd",
    recurring={"interval": "month"},
    metadata={"tier": "pro", "runs_included": "100000"}
)

pro_runs_overage = stripe.Price.create(
    product=pro_product.id,
    currency="usd",
    recurring={
        "interval": "month",
        "usage_type": "metered",
        "aggregate_usage": "sum",
    },
    unit_amount_decimal="0.05",  # $0.0005 per run = $0.50 per 1K
    metadata={"type": "runs_overage"}
)

Database Schema

-- Users table (synced from Clerk + Stripe)
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    clerk_id VARCHAR(255) UNIQUE NOT NULL,
    email VARCHAR(255) NOT NULL,
    name VARCHAR(255),

    -- Stripe fields
    stripe_customer_id VARCHAR(255) UNIQUE,
    stripe_subscription_id VARCHAR(255),
    stripe_subscription_item_id VARCHAR(255),  -- For usage reporting

    -- Billing state (cached from Stripe)
    tier VARCHAR(50) DEFAULT 'free',  -- free, pro, enterprise
    billing_status VARCHAR(50) DEFAULT 'active',  -- active, past_due, canceled
    trial_ends_at TIMESTAMPTZ,
    current_period_start TIMESTAMPTZ,
    current_period_end TIMESTAMPTZ,

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

-- Usage tracking (local, for dashboard + Stripe sync)
CREATE TABLE usage_records (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID REFERENCES users(id),
    period_start DATE NOT NULL,  -- Billing period start

    -- Metrics
    workflow_runs INT DEFAULT 0,
    llm_tokens_in INT DEFAULT 0,
    llm_tokens_out INT DEFAULT 0,
    wasm_cpu_seconds DECIMAL(10,2) DEFAULT 0,
    storage_gb_hours DECIMAL(10,2) DEFAULT 0,

    -- Stripe sync state
    last_synced_at TIMESTAMPTZ,
    last_synced_runs INT DEFAULT 0,

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

    UNIQUE(user_id, period_start)
);

-- Stripe webhook events (idempotency)
CREATE TABLE stripe_events (
    event_id VARCHAR(255) PRIMARY KEY,
    event_type VARCHAR(100) NOT NULL,
    processed_at TIMESTAMPTZ DEFAULT NOW(),
    payload JSONB
);

-- Index for cleanup
CREATE INDEX idx_stripe_events_processed ON stripe_events(processed_at);

Usage Tracking

Real-Time Counting (Redis)

# On every workflow execution
async def record_workflow_run(user_id: str):
    """Increment run counter in Redis."""
    key = f"usage:{user_id}:runs:{get_current_period()}"
    await redis.incr(key)
    await redis.expire(key, 86400 * 35)  # 35 days TTL

    # Track users with usage for batch sync
    await redis.sadd("users:with_usage", user_id)

async def record_llm_tokens(user_id: str, tokens_in: int, tokens_out: int):
    """Track LLM token usage."""
    period = get_current_period()
    await redis.incrby(f"usage:{user_id}:tokens_in:{period}", tokens_in)
    await redis.incrby(f"usage:{user_id}:tokens_out:{period}", tokens_out)

Periodic Sync to Stripe (Hourly)

async def sync_usage_to_stripe():
    """Hourly job: push usage increments to Stripe."""

    user_ids = await redis.smembers("users:with_usage")

    for user_id in user_ids:
        user = await get_user(user_id)
        if not user.stripe_subscription_item_id:
            continue  # Free tier without Stripe subscription

        # Get usage since last sync
        period = get_current_period()
        runs_key = f"usage:{user_id}:runs:{period}"

        current_runs = int(await redis.get(runs_key) or 0)
        last_synced = await get_last_synced_runs(user_id, period)

        delta = current_runs - last_synced
        if delta <= 0:
            continue

        # Check if over included limit
        tier_limit = get_tier_runs_limit(user.tier)  # 1000 or 100000
        if current_runs <= tier_limit:
            # Still within included runs, just track locally
            await update_last_synced(user_id, period, current_runs)
            continue

        # Calculate overage to report
        overage_start = max(last_synced, tier_limit)
        overage_runs = current_runs - overage_start

        if overage_runs > 0:
            # Report to Stripe
            await stripe.subscription_items.create_usage_record(
                user.stripe_subscription_item_id,
                quantity=overage_runs,
                timestamp=int(time.time()),
                action='increment'
            )

        await update_last_synced(user_id, period, current_runs)

    # Clear the tracking set (will rebuild next hour)
    await redis.delete("users:with_usage")

Dashboard Query

async def get_usage_dashboard(user_id: str) -> UsageDashboard:
    """Get current usage for user dashboard."""
    user = await get_user(user_id)
    period = get_current_period()

    # Get real-time counts from Redis
    runs = int(await redis.get(f"usage:{user_id}:runs:{period}") or 0)
    tokens_in = int(await redis.get(f"usage:{user_id}:tokens_in:{period}") or 0)
    tokens_out = int(await redis.get(f"usage:{user_id}:tokens_out:{period}") or 0)

    tier_limit = get_tier_runs_limit(user.tier)

    return UsageDashboard(
        period_start=period,
        period_end=user.current_period_end,

        runs_used=runs,
        runs_limit=tier_limit,
        runs_percentage=min(100, (runs / tier_limit) * 100),

        tokens_used=tokens_in + tokens_out,

        estimated_overage=calculate_overage_cost(runs, tier_limit),

        days_remaining=(user.current_period_end - datetime.now()).days,
    )

Subscription Lifecycle

Signup Flow

User clicks "Start Free Trial"
        │
        ▼
┌───────────────────────────────────────────────────────────┐
│ 1. Create Stripe Customer                                  │
│                                                            │
│    customer = stripe.Customer.create(                      │
│        email=user.email,                                   │
│        metadata={"clerk_id": user.clerk_id}                │
│    )                                                       │
└───────────────────────────────────────────────────────────┘
        │
        ▼
┌───────────────────────────────────────────────────────────┐
│ 2. Create Checkout Session (hosted payment page)           │
│                                                            │
│    session = stripe.checkout.Session.create(               │
│        customer=customer.id,                               │
│        mode='subscription',                                │
│        line_items=[{                                       │
│            'price': 'price_pro_monthly',                   │
│            'quantity': 1                                   │
│        }, {                                                │
│            'price': 'price_pro_runs_overage',  # metered   │
│        }],                                                 │
│        subscription_data={                                 │
│            'trial_period_days': 14,                        │
│        },                                                  │
│        success_url='https://app.openblox.ai/welcome',      │
│        cancel_url='https://app.openblox.ai/pricing',       │
│    )                                                       │
│                                                            │
│    → Redirect user to session.url                          │
└───────────────────────────────────────────────────────────┘
        │
        ▼
┌───────────────────────────────────────────────────────────┐
│ 3. User enters payment details on Stripe Checkout          │
│                                                            │
│    Card validated but NOT charged (trial)                  │
└───────────────────────────────────────────────────────────┘
        │
        ▼
┌───────────────────────────────────────────────────────────┐
│ 4. Webhook: checkout.session.completed                     │
│                                                            │
│    → Update user with stripe_customer_id                   │
│    → Update user with stripe_subscription_id               │
│    → Set tier = 'pro'                                      │
│    → Set trial_ends_at                                     │
└───────────────────────────────────────────────────────────┘

Trial End

Day 11 of 14-day trial
        │
        ▼
┌───────────────────────────────────────────────────────────┐
│ Scheduled job: Trial ending soon emails                    │
│                                                            │
│ SELECT * FROM users                                        │
│ WHERE trial_ends_at BETWEEN NOW() AND NOW() + INTERVAL '3d'│
│ AND billing_status = 'trialing'                            │
│                                                            │
│ → Send "Your trial ends in 3 days" email                   │
└───────────────────────────────────────────────────────────┘
        │
        ▼
Day 14: Trial ends
        │
        ▼
┌───────────────────────────────────────────────────────────┐
│ Stripe automatically:                                      │
│ 1. Charges the card on file                               │
│ 2. Sends invoice.payment_succeeded webhook                │
│                                                            │
│ Our webhook handler:                                       │
│ → Update billing_status = 'active'                        │
│ → Send "Welcome to Pro!" email                            │
└───────────────────────────────────────────────────────────┘

Cancellation

# User clicks "Cancel subscription" in Customer Portal
# Stripe sends webhook

@webhook("customer.subscription.updated")
async def handle_subscription_updated(event):
    subscription = event.data.object
    user = await get_user_by_stripe_subscription(subscription.id)

    if subscription.cancel_at_period_end:
        # User requested cancellation (takes effect at period end)
        await send_email(user, "subscription_canceled", {
            "effective_date": subscription.current_period_end
        })
        await db.execute("""
            UPDATE users
            SET billing_status = 'canceling',
                updated_at = NOW()
            WHERE id = $1
        """, user.id)

@webhook("customer.subscription.deleted")
async def handle_subscription_deleted(event):
    subscription = event.data.object
    user = await get_user_by_stripe_subscription(subscription.id)

    # Subscription actually ended
    await db.execute("""
        UPDATE users
        SET tier = 'free',
            billing_status = 'canceled',
            stripe_subscription_id = NULL,
            stripe_subscription_item_id = NULL,
            updated_at = NOW()
        WHERE id = $1
    """, user.id)

    await send_email(user, "downgraded_to_free")

Webhook Handlers

Endpoint Setup

from fastapi import FastAPI, Request, HTTPException
import stripe

app = FastAPI()

@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
    payload = await request.body()
    sig_header = request.headers.get("stripe-signature")

    try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
        )
    except ValueError:
        raise HTTPException(400, "Invalid payload")
    except stripe.error.SignatureVerificationError:
        raise HTTPException(400, "Invalid signature")

    # Idempotency check
    if await is_event_processed(event.id):
        return {"status": "already_processed"}

    # Route to handler
    handler = WEBHOOK_HANDLERS.get(event.type)
    if handler:
        await handler(event)
    else:
        logger.info(f"Unhandled webhook: {event.type}")

    # Mark processed
    await mark_event_processed(event)

    return {"status": "success"}

Handler Registry

WEBHOOK_HANDLERS = {
    # Checkout
    "checkout.session.completed": handle_checkout_completed,

    # Subscriptions
    "customer.subscription.created": handle_subscription_created,
    "customer.subscription.updated": handle_subscription_updated,
    "customer.subscription.deleted": handle_subscription_deleted,
    "customer.subscription.trial_will_end": handle_trial_ending,

    # Payments
    "invoice.payment_succeeded": handle_payment_succeeded,
    "invoice.payment_failed": handle_payment_failed,
    "invoice.upcoming": handle_invoice_upcoming,

    # Customer
    "customer.updated": handle_customer_updated,
}

Key Handlers

@webhook("checkout.session.completed")
async def handle_checkout_completed(event):
    """User completed checkout - provision their account."""
    session = event.data.object

    # Get or create user
    user = await get_user_by_clerk_id(session.client_reference_id)

    # Update with Stripe IDs
    subscription = await stripe.Subscription.retrieve(session.subscription)

    await db.execute("""
        UPDATE users SET
            stripe_customer_id = $1,
            stripe_subscription_id = $2,
            stripe_subscription_item_id = $3,
            tier = $4,
            billing_status = $5,
            trial_ends_at = $6,
            current_period_start = $7,
            current_period_end = $8,
            updated_at = NOW()
        WHERE id = $9
    """,
        session.customer,
        subscription.id,
        subscription['items'].data[0].id,  # First item for usage reporting
        'pro',
        subscription.status,  # 'trialing' or 'active'
        datetime.fromtimestamp(subscription.trial_end) if subscription.trial_end else None,
        datetime.fromtimestamp(subscription.current_period_start),
        datetime.fromtimestamp(subscription.current_period_end),
        user.id
    )


@webhook("invoice.payment_failed")
async def handle_payment_failed(event):
    """Payment failed - notify user, potentially downgrade."""
    invoice = event.data.object
    user = await get_user_by_stripe_customer(invoice.customer)

    attempt_count = invoice.attempt_count

    if attempt_count == 1:
        # First failure - soft warning
        await send_email(user, "payment_failed_soft", {
            "amount": invoice.amount_due / 100,
            "update_url": await get_customer_portal_url(user)
        })

    elif attempt_count == 2:
        # Second failure - stronger warning
        await send_email(user, "payment_failed_warning", {
            "amount": invoice.amount_due / 100,
            "days_until_downgrade": 3
        })

    else:
        # Final failure - downgrade
        await db.execute("""
            UPDATE users SET
                tier = 'free',
                billing_status = 'past_due',
                updated_at = NOW()
            WHERE id = $1
        """, user.id)

        await send_email(user, "downgraded_payment_failed")


@webhook("customer.subscription.trial_will_end")
async def handle_trial_ending(event):
    """Trial ending in 3 days - Stripe sends this automatically."""
    subscription = event.data.object
    user = await get_user_by_stripe_subscription(subscription.id)

    await send_email(user, "trial_ending", {
        "trial_end_date": datetime.fromtimestamp(subscription.trial_end),
        "amount": 29.00,  # Pro price
        "manage_url": await get_customer_portal_url(user)
    })

Customer Portal

Stripe's hosted portal for self-service billing management.

async def get_customer_portal_url(user: User) -> str:
    """Generate a portal session URL for the user."""
    session = await stripe.billing_portal.Session.create(
        customer=user.stripe_customer_id,
        return_url="https://app.openblox.ai/settings/billing"
    )
    return session.url

Portal capabilities:

  • Update payment method
  • View invoices and receipts
  • Cancel subscription
  • Upgrade/downgrade plan (if configured)

Email Templates

Trigger Template Content
Trial started trial_started Welcome, trial ends on X
Trial ending (3 days) trial_ending Your trial ends soon, card will be charged
Trial converted trial_converted Welcome to Pro!
Payment succeeded payment_succeeded Receipt attached
Payment failed (1st) payment_failed_soft Please update your card
Payment failed (2nd) payment_failed_warning Service will be interrupted
Payment failed (final) downgraded_payment_failed You've been downgraded
Subscription canceled subscription_canceled Access until period end
Downgraded downgraded_to_free You're now on Free

Rate Limiting & Abuse Prevention

Soft Limits (Warning)

async def check_usage_limits(user_id: str) -> UsageLimitResult:
    """Check if user is approaching limits."""
    usage = await get_current_usage(user_id)
    user = await get_user(user_id)
    tier_limit = get_tier_runs_limit(user.tier)

    percentage = (usage.runs / tier_limit) * 100

    if percentage >= 100:
        return UsageLimitResult(
            allowed=True,  # Still allow, but warn
            warning="You've exceeded your included runs. Overage charges apply.",
            overage_rate="$0.50 per 1,000 runs"
        )
    elif percentage >= 80:
        return UsageLimitResult(
            allowed=True,
            warning=f"You've used {percentage:.0f}% of your monthly runs."
        )

    return UsageLimitResult(allowed=True)

Hard Limits (Free Tier)

async def enforce_free_tier_limits(user_id: str) -> bool:
    """Free tier has hard limits - no overage allowed."""
    user = await get_user(user_id)
    if user.tier != "free":
        return True  # Paid tiers have soft limits

    usage = await get_current_usage(user_id)
    if usage.runs >= 1000:
        raise UsageLimitExceeded(
            "You've reached the Free tier limit of 1,000 runs/month. "
            "Upgrade to Pro for unlimited workflows."
        )

    return True

Testing

Test Mode

Stripe provides test mode with test API keys and test card numbers.

# .env
STRIPE_SECRET_KEY=sk_test_...  # Test mode
STRIPE_WEBHOOK_SECRET=whsec_...

# Test cards
# 4242424242424242 - Succeeds
# 4000000000000002 - Declined
# 4000002500003155 - Requires 3D Secure

Webhook Testing

# Use Stripe CLI to forward webhooks locally
stripe listen --forward-to localhost:8000/webhooks/stripe

# Trigger test events
stripe trigger invoice.payment_succeeded
stripe trigger customer.subscription.trial_will_end

Monitoring & Alerts

Metric Alert Threshold
Webhook processing time > 5 seconds
Webhook failure rate > 1%
Payment failure rate > 5%
Usage sync lag > 2 hours
Stripe API errors Any 5xx

Security Checklist

  • Webhook signature verification
  • Idempotent event processing
  • API keys in environment variables (never in code)
  • Customer portal for sensitive operations (not custom UI)
  • PCI compliance via Stripe Checkout (no card data touches our servers)
  • Audit log for billing events

References