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:
Extended payment status with free tier context:
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¶
- Conversational tone: Matches Sage's personality (lowercase, emojis, casual)
- No pressure: Offers upgrade without being pushy
- Clear value: Explains what user gets with subscription
- 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¶
- Phase 1:
FREE_TIER_ENABLED = false(shipped off) - Phase 2: Enable for 10% new users via LaunchDarkly
- Phase 3: Monitor conversion and retention metrics
- 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_subscribedresult per request (already via user object) - Quota status cached in database (window-based)
Security Considerations¶
- User isolation: Quotas scoped by user_id
- Group authorization: Only room owner subscription checked
- Rate limiting: Existing rate limits apply on top
- Payment link security: Uses Stripe-hosted checkout
Monitoring¶
Key Metrics¶
free_tier.message_limit_hit: Count of users hitting limitfree_tier.superpower_blocked: Superpower block eventsfree_tier.upgrade_clicks: Payment link click-throughssubscription.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:
- If superpower is equipped → allow (supports grandfathering)
- If not equipped and slots full → return
slot_limit_reached - 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 blocked — LoadoutService.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) |
Related Documentation¶
- 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