Skip to content

Free Tier System Architecture

Last Updated: 2026-02-16 Status: Implemented

Overview

The free tier system enables a freemium pricing model that allows users to experience Ikiro's core chat functionality without payment while gating premium features (superpowers) behind a subscription.

System Design

Pricing Model

Tier Price Messages Superpowers (1:1) Superpowers (Group)
Free $0 50/day Disabled Enabled if group creator subscribed
Monthly $7.99/mo Unlimited Full access Full access
Annual $50/yr Unlimited Full access Full access

Architecture Diagram

┌─────────────────────────────────────────────────────────────────────────┐
│                           Message Flow                                   │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  User Message                                                            │
│       │                                                                  │
│       ▼                                                                  │
│  ┌─────────────────────┐                                                │
│  │   MessageHandler    │                                                │
│  │ handle_message_async│                                                │
│  └──────────┬──────────┘                                                │
│             │                                                            │
│             ▼                                                            │
│  ┌─────────────────────────────────────────────┐                        │
│  │     SubscriptionAccessService                │                        │
│  │  ┌─────────────────────────────────────────┐│                        │
│  │  │ check_message_limit(user)               ││ ◄── 50/day for free    │
│  │  │  └─► RateLimitService                   ││     users              │
│  │  │       └─► check_free_tier_daily_limit() ││                        │
│  │  └─────────────────────────────────────────┘│                        │
│  └──────────────────────┬──────────────────────┘                        │
│                         │                                                │
│         ┌───────────────┴───────────────┐                               │
│         │                               │                                │
│    [allowed]                      [limit reached]                        │
│         │                               │                                │
│         ▼                               ▼                                │
│  Continue to                    Return upgrade                           │
│  process message               message (in-persona)                      │
│         │                                                                │
│         ▼                                                                │
│  ┌─────────────────────┐                                                │
│  │ record_message()    │ ◄── Increment daily counter                    │
│  └─────────────────────┘                                                │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────────┐
│                        Superpower Flow                                   │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  Workflow Trigger                                                        │
│       │                                                                  │
│       ▼                                                                  │
│  ┌─────────────────────┐                                                │
│  │   WorkflowEngine    │                                                │
│  │  execute_workflow() │                                                │
│  └──────────┬──────────┘                                                │
│             │                                                            │
│             ▼                                                            │
│  ┌─────────────────────────────────────────────┐                        │
│  │     SubscriptionAccessService                │                        │
│  │  ┌─────────────────────────────────────────┐│                        │
│  │  │ can_use_superpower(user, room, is_group)││                        │
│  │  │   ├─► 1:1: Check user subscription      ││                        │
│  │  │   └─► Group: Check room owner sub       ││                        │
│  │  └─────────────────────────────────────────┘│                        │
│  └──────────────────────┬──────────────────────┘                        │
│                         │                                                │
│         ┌───────────────┴───────────────┐                               │
│         │                               │                                │
│    [allowed]                       [blocked]                             │
│         │                               │                                │
│         ▼                               ▼                                │
│  Execute workflow              Return blocked status                     │
│  nodes normally               with upgrade message                       │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

Components

1. SubscriptionAccessService

Location: app/services/subscription_access_service.py

Centralized service for all subscription-based access control decisions.

class SubscriptionAccessService:
    """Centralized subscription and access control checks."""

    def __init__(self, db: Session):
        self.db = db
        self.rate_limit_service = RateLimitService(db)

    def is_subscribed(self, user: User) -> bool:
        """Check if user has active subscription."""
        return user.subscription_status in ('active', 'trial')

    def check_message_limit(self, user: User) -> Tuple[bool, Optional[str], int]:
        """
        Check if free user has messages remaining today.

        Returns:
            (allowed, error_code, remaining_count)
            - remaining_count is -1 for unlimited
        """

    def can_use_superpower(
        self,
        user: User,
        room_id: Optional[UUID] = None,
        is_group: bool = False
    ) -> Tuple[bool, Optional[str]]:
        """
        Check if user can use superpowers.

        Rules:
        - 1:1: User must be subscribed
        - Group: Room owner must be subscribed
        """

    def record_message(self, user: User) -> None:
        """Record message for rate limiting (free users only)."""

    def get_upgrade_message_for_limit(self) -> str:
        """Get in-persona upgrade message for message limit."""

    def get_upgrade_message_for_superpower_direct(self) -> str:
        """Get upgrade message for superpower block in 1:1."""

    def get_upgrade_message_for_superpower_group(self) -> str:
        """Get message for superpower block in group (owner not subscribed)."""

2. RateLimitService Extensions

Location: app/services/rate_limit_service.py

Extended with free tier daily message tracking.

def check_free_tier_daily_limit(
    self,
    user_id: UUID,
    limit: int = 50,
) -> Tuple[bool, int]:
    """
    Check if free user has daily messages remaining.

    Uses quota_type='free_daily_messages' with 24-hour window.

    Returns:
        (allowed, remaining_count)
    """

def record_free_tier_message(self, user_id: UUID) -> None:
    """Record a message for free tier user."""

3. Feature Flags

Location: app/feature_flags/flags.py

class FreeTierFlags:
    """Feature flags for free tier functionality."""

    FREE_TIER_ENABLED = "free_tier.enabled"
    FREE_TIER_ENABLED_DEFAULT = True

    FREE_TIER_DAILY_MESSAGE_LIMIT = "free_tier.daily_message_limit"
    FREE_TIER_DAILY_MESSAGE_LIMIT_DEFAULT = 50

    FREE_TIER_SUPERPOWERS_DISABLED = "free_tier.superpowers_disabled"
    FREE_TIER_SUPERPOWERS_DISABLED_DEFAULT = True

Database Schema

Rate Limit Quotas Table

Uses existing rate_limit_quotas table:

CREATE TABLE rate_limit_quotas (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    quota_type VARCHAR(50) NOT NULL,      -- 'free_daily_messages'
    scope_id VARCHAR(255) NOT NULL,       -- User UUID
    quota_limit INTEGER NOT NULL,          -- 50
    quota_window INTEGER NOT NULL,         -- 86400 (24 hours)
    current_count INTEGER DEFAULT 0,       -- Messages sent
    window_start TIMESTAMP DEFAULT NOW(),  -- Window start time
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),
    UNIQUE(quota_type, scope_id)
);

User Subscription Fields

Uses existing fields on users table:

-- Subscription status tracking
subscription_status VARCHAR(20),  -- 'free', 'active', 'trial', 'past_due', 'canceled'
subscription_plan VARCHAR(20),    -- 'monthly', 'annual'
subscription_id VARCHAR(255),     -- Stripe subscription ID

Integration Points

1. Message Handler

Location: app/orchestrator/message_handler.py

Added free tier check early in handle_message_async():

async def handle_message_async(self, request, send_func, pre_filtered=False, db=None):
    # ... existing code ...

    # Get user ID
    user_id = self._get_or_create_user_id(user_phone)

    # FREE TIER CHECK
    access_service = get_subscription_access_service(db)
    allowed, error_code, remaining = access_service.check_message_limit(user)

    if not allowed:
        upgrade_response = access_service.get_upgrade_message_for_limit()
        await send_func(upgrade_response)
        return True

    # Record message usage
    access_service.record_message(user)

    # ... continue processing ...

2. Workflow Engine

Location: app/workflows/engine.py

Added superpower check at start of execute_workflow():

async def execute_workflow(self, workflow_id, user_id, trigger_data, room_id=None):
    # Load workflow definition
    workflow = workflow_registry.get(workflow_id)

    # SUPERPOWER ACCESS CHECK
    access_service = get_subscription_access_service(db)
    is_group = trigger_data.get("is_group", False)

    allowed, error_code = access_service.can_use_superpower(
        user=user_obj,
        room_id=room_uuid,
        is_group=is_group
    )

    if not allowed:
        return ExecutionResult(
            status="blocked",
            error=error_code,
            data={
                "requires_subscription": True,
                "upgrade_message": upgrade_msg,
            }
        )

    # ... continue workflow execution ...

3. API Endpoints

Location: app/api/usage_routes.py, app/api/payment_routes.py

New endpoint for usage limits:

GET /usage/limits/{user_id}

Extended payment status with free tier context:

GET /payment/status

Stripe Integration

The free tier integrates with existing Stripe subscription system:

Config Settings

# app/config.py
stripe_price_monthly: Optional[str] = None   # Price ID for $7.99/mo
stripe_price_annual: Optional[str] = None    # Price ID for $50/yr
stripe_payment_link_monthly: Optional[str] = None  # Payment link for upgrades

Webhook Handlers

Existing handlers automatically update subscription_status:

Event Status Update
customer.subscription.created active
customer.subscription.updated Updates based on Stripe
customer.subscription.deleted canceled
invoice.payment_failed past_due

In-Persona Messaging

Upgrade prompts are delivered as Sage responses, not system errors:

Design Principles

  1. Conversational tone: Matches Sage's personality (lowercase, emojis, casual)
  2. No pressure: Offers upgrade without being pushy
  3. Clear value: Explains what user gets with subscription
  4. Easy action: Includes direct payment link

Message Templates

Located in SubscriptionAccessService.get_upgrade_message_*() methods.

Error Codes

Code Context Description
message_limit_reached MessageHandler 50/day limit exceeded
superpower_requires_subscription WorkflowEngine 1:1 superpower blocked
group_owner_not_subscribed WorkflowEngine Group superpower blocked

Feature Flag Rollout

Gradual Rollout Strategy

  1. Phase 1: FREE_TIER_ENABLED = false (shipped off)
  2. Phase 2: Enable for 10% new users via LaunchDarkly
  3. Phase 3: Monitor conversion and retention metrics
  4. Phase 4: Full rollout if metrics are positive

LaunchDarkly Targeting

{
  "flag": "free_tier.enabled",
  "targeting": {
    "rules": [
      {
        "attribute": "created_at",
        "op": "after",
        "value": "2026-01-15",
        "rollout": {"percentage": 10}
      }
    ],
    "fallthrough": {"value": false}
  }
}

Performance Considerations

Database Queries

  • Message limit check: 1 query to rate_limit_quotas
  • Superpower check: 1-2 queries (user + room if group)

Caching Opportunities

  • Cache is_subscribed result per request (already via user object)
  • Quota status cached in database (window-based)

Security Considerations

  1. User isolation: Quotas scoped by user_id
  2. Group authorization: Only room owner subscription checked
  3. Rate limiting: Existing rate limits apply on top
  4. Payment link security: Uses Stripe-hosted checkout

Monitoring

Key Metrics

  • free_tier.message_limit_hit: Count of users hitting limit
  • free_tier.superpower_blocked: Superpower block events
  • free_tier.upgrade_clicks: Payment link click-throughs
  • subscription.conversion_rate: Free → Paid conversion

Alerts

  • High rate of limit hits without conversions
  • Unusual patterns in message volume
  • Stripe webhook failures

PRD 12 Phase 1: Billing Hardening

Added in February 2026 to close gaps identified in PRD 12 (Billing & Monetization).

Upgrade Nag Frequency

Location: SubscriptionAccessService.should_suggest_upgrade(), record_upgrade_prompt()

Controls how often free users see upgrade suggestions: - Max 1 per week: Tracked via users.last_upgrade_prompt_at column - Never during emotional conversations: is_emotional flag suppresses prompts - Never after same-conversation decline: Handled by caller (MessageHandler/WorkflowEngine)

def should_suggest_upgrade(self, user, is_emotional=False) -> bool:
    if is_emotional:
        return False
    if user.last_upgrade_prompt_at:
        week_ago = datetime.now(timezone.utc) - timedelta(days=7)
        if user.last_upgrade_prompt_at > week_ago:
            return False
    return True

Migration: supabase/migrations/20260222000000_billing_phase1_hardening.sql adds last_upgrade_prompt_at column and index.

Billing Telemetry

Location: app/services/billing_telemetry.py

Structured log events for billing analytics funnel:

Event Emitted When Key Properties
billing_upgrade_prompted Upgrade message shown user_id, companion_id, trigger
billing_upgrade_clicked Checkout session created user_id, plan, source
billing_subscription_started Stripe subscription created user_id, plan, interval, amount_cents
billing_subscription_canceled Stripe subscription deleted user_id, plan, interval, days_active
billing_limit_hit Free tier limit reached user_id, limit_type, current_value
billing_payment_failed Invoice payment failed user_id, interval, failure_reason
billing_winback_sent Re-engagement message sent user_id, days_since_cancel

All events are emitted via logger.info("BILLING_EVENT: ...") with structured billing_event extra data. Telemetry calls are wrapped in try/except in payment webhooks to never break critical flows.

Slot-Count-Aware Enforcement

Location: SubscriptionAccessService.can_use_superpower()

When the slot system is enabled (progression.slot_system.enabled flag), superpower access is checked against the loadout:

  1. If superpower is equipped → allow (supports grandfathering)
  2. If not equipped and slots full → return slot_limit_reached
  3. If not equipped and slots available → return superpower_not_equipped

Slot allocation: Free users get 1 slot, paid users get 3-6 based on companion level (via get_slot_allocation()).

Grandfathering on Downgrade

When a paid user cancels their subscription: - Equipped superpowers remain usable — the is_equipped check in can_use_superpower() returns True before checking subscription status - New equips above free cap are blockedLoadoutService.equip_superpower() enforces slot limits at equip time - Users keep their loadout — no automatic unequipping on downgrade

Error Codes (Updated)

Code Context Description
message_limit_reached MessageHandler 50/day limit exceeded
superpower_requires_subscription WorkflowEngine 1:1 superpower blocked
group_owner_not_subscribed WorkflowEngine Group superpower blocked
slot_limit_reached WorkflowEngine All loadout slots full
superpower_not_equipped WorkflowEngine Superpower not in loadout (slots available)
  • API Specification: docs/apis/subscription-access-api.md
  • Rate Limit Service: app/services/rate_limit_service.py
  • Subscription Access Service: app/services/subscription_access_service.py
  • Stripe Integration: See app/payment/ for implementation