Building a consumer AI app in 2025 means fighting for attention in a market flooded with ads. I went the opposite direction: invite-only growth.
My project, BiasSecret, is an AI-powered K-pop dating simulator where each AI companion has a unique personality, memory, and conversation style powered by DeepSeek. Instead of pouring money into paid acquisition, I built a self-sustaining invite system that turns every user into a growth channel.
Here's the full technical breakdown of how it works — invite code generation, unlock mechanics, anti-abuse filters, and the viral loop that makes it all click.
The Architecture: Invite Codes as Growth Levers
The core idea is simple: new users get 30 free conversation rounds. To unlock unlimited play, they either need an invite code from an existing user or a license key purchased on Gumroad ($4.99). Every registered user gets 5 invite codes to share. When someone they invite signs up and plays, both parties earn an extra invite — creating a compound growth loop.
Let's dig into the code.
1. Invite Code Generation (FastAPI + SQLAlchemy)
Every invite code needs to be unique, tamper-resistant, and tied to its issuer. I use secrets.token_urlsafe for cryptographic randomness paired with a database-backed dedup check.
# app/services/invite_service.py
import secrets
import string
from sqlalchemy.orm import Session
from app.models import InviteCode, User
from app.database import get_db
def generate_invite_code(length: int = 10) -> str:
"""Generate a cryptographically random invite code."""
alphabet = string.ascii_uppercase + string.digits
code = ''.join(secrets.choice(alphabet) for _ in range(length))
# Avoid ambiguous characters
for c in ['0', 'O', 'I', 'l']:
code = code.replace(c, secrets.choice('ABCDEFGHJKMNPQRSTUVWXYZ23456789'))
return code
def create_invite_codes_for_user(user_id: int, db: Session, count: int = 5) -> list[str]:
"""Issue N unique invite codes to a user."""
codes = []
for _ in range(count):
while True:
raw_code = generate_invite_code()
# Ensure uniqueness at the DB level
exists = db.query(InviteCode).filter(InviteCode.code == raw_code).first()
if not exists:
break
invite = InviteCode(
code=raw_code,
issuer_id=user_id,
is_used=False,
created_at=datetime.utcnow()
)
db.add(invite)
codes.append(raw_code)
db.commit()
return codes
def refill_invites_if_needed(user_id: int, db: Session):
"""Ensure every active user always has at least 5 invite codes available."""
available = db.query(InviteCode).filter(
InviteCode.issuer_id == user_id,
InviteCode.is_used == False
).count()
if available < 5:
new_count = 5 - available
create_invite_codes_for_user(user_id, db, count=new_count)
Key decisions here:
-
secretsmodule instead ofrandom— cryptographically secure, no predictability -
Ambiguous character removal — users typing codes on mobile won't confuse
0vsO - DB-level uniqueness loop — collision probability is astronomically low (~1 in 47^10), but the loop is a safety net
- Auto-refill — users never run out of invites; the system tops them up after each successful referral
2. The Unlock Model: Tracking Who Gets What
The unlock system needs to track three dimensions: what was unlocked, who unlocked it, and how (invite vs. purchase).
# app/models.py
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Enum
from sqlalchemy.orm import relationship
from app.database import Base
import enum
class UnlockMethod(str, enum.Enum):
INVITE_CODE = "invite_code"
LICENSE_KEY = "license_key"
TRIAL = "trial" # 30 free rounds
class Unlock(Base):
__tablename__ = "unlocks"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
method = Column(Enum(UnlockMethod), nullable=False)
reference_code = Column(String(32), nullable=True) # invite code or license key used
inviter_id = Column(Integer, ForeignKey("users.id"), nullable=True)
rounds_granted = Column(Integer, default=999999) # unlimited for paid/invite
expires_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", back_populates="unlocks", foreign_keys=[user_id])
inviter = relationship("User", back_populates="referrals", foreign_keys=[inviter_id])
class InviteCode(Base):
__tablename__ = "invite_codes"
id = Column(Integer, primary_key=True, index=True)
code = Column(String(16), unique=True, nullable=False, index=True)
issuer_id = Column(Integer, ForeignKey("users.id"), nullable=False)
used_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
is_used = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
used_at = Column(DateTime, nullable=True)
The Unlock model is deliberately generic — it handles invites, license keys, and trial rounds through the same schema. The inviter_id field is what powers the referral graph: I can run queries like "who referred the most active users in the last 7 days" to spot power users.
# app/services/unlock_service.py
from sqlalchemy.orm import Session, joinedload
from app.models import Unlock, InviteCode, User, UnlockMethod
def apply_invite_code(code: str, user_id: int, db: Session) -> dict:
"""Redeem an invite code. Returns success/failure with message."""
invite = db.query(InviteCode).options(
joinedload(InviteCode.issuer)
).filter(InviteCode.code == code).first()
if not invite:
return {"success": False, "message": "Invalid invite code."}
if invite.is_used:
return {"success": False, "message": "This code has already been used."}
if invite.issuer_id == user_id:
return {"success": False, "message": "You can't use your own invite code."}
# Create unlock record
unlock = Unlock(
user_id=user_id,
method=UnlockMethod.INVITE_CODE,
reference_code=code,
inviter_id=invite.issuer_id,
rounds_granted=999999
)
db.add(unlock)
# Mark invite as used
invite.is_used = True
invite.used_by_id = user_id
invite.used_at = datetime.utcnow()
# Reward the inviter with +1 extra invite
refill_invites_if_needed(invite.issuer_id, db)
# Reward the new user with +1 extra invite (they can now invite others)
refill_invites_if_needed(user_id, db)
db.commit()
return {"success": True, "message": "Unlimited play unlocked! You also earned an invite code to share."}
3. Anti-Abuse: IP Dedup & Email Filters
Growth loops attract abuse. Without protections, a single user could register 50 accounts, redeem 50 invite codes, and game the system. Here's the defense layer:
# app/middleware/anti_abuse.py
from sqlalchemy.orm import Session
from app.models import User, InviteCode, Unlock, UnlockMethod
from datetime import datetime, timedelta
from ipaddress import ip_address
class AbuseDetector:
"""Multi-layer abuse prevention for invite system."""
@staticmethod
def check_ip_duplicate(ip: str, db: Session) -> dict:
"""
Reject invite-based unlock if the IP has already been used
for another account that also used an invite code.
This prevents one person farming invites by creating alt accounts.
"""
recent_registrations = db.query(User).filter(
User.registration_ip == ip,
User.created_at >= datetime.utcnow() - timedelta(days=30)
).all()
for reg_user in recent_registrations:
their_unlock = db.query(Unlock).filter(
Unlock.user_id == reg_user.id,
Unlock.method == UnlockMethod.INVITE_CODE
).first()
if their_unlock:
return {
"blocked": True,
"reason": "This IP has already redeemed an invite code on another account within 30 days."
}
return {"blocked": False}
@staticmethod
def check_email_domain_abuse(email: str, db: Session) -> bool:
"""Block temporary / disposable email domains."""
import re
disposable_domains = {"tempmail.com", "mailinator.com", "guerrillamail.com",
"10minutemail.com", "throwaway.email", "yopmail.com"}
domain = email.split("@")[-1].lower()
if domain in disposable_domains:
return True
# Also catch subdomain variants like user@tempmail.co
base = ".".join(domain.split(".")[-2:])
return base in disposable_domains
@staticmethod
def check_rate_limit(ip: str, db: Session) -> dict:
"""Max 3 invite code attempts per IP per hour."""
one_hour_ago = datetime.utcnow() - timedelta(hours=1)
attempts = db.query(InviteCode).filter(
InviteCode.used_by_id == None,
InviteCode.attempted_at >= one_hour_ago,
InviteCode.attempt_ip == ip
).count()
if attempts >= 3:
return {"blocked": True, "retry_after": 3600}
return {"blocked": False}
The IP dedup check is the most important line of defense — it prevents a single user from creating multiple accounts to farm invite rewards. Combined with disposable email blocking and rate limiting, abuse dropped to near zero in production.
4. The Growth Loop: Compound Referral Mechanics
Here's where the math gets interesting. Each user starts with 5 invites. When Alice invites Bob:
- Alice gets +1 invite (refill)
- Bob gets +1 invite (refill)
- Bob now has 6 invites to share
- If each invited user invites just 2 others... you get viral growth
User | Invites Sent | New Users | Total Network
------|-------------|-----------|---------------
Alice | 5 | 5 | 5
Bob | 5 | 5 | 10
Carol | 5 | 5 | 15
...
In practice, conversion rate from invite receipt to signup is about 40% — much higher than cold traffic because the invite comes from a trusted friend who's already enjoying the experience.
5. Production Metrics
After 3 months of running this system:
- ~65% of new users come through invite codes (organic)
- ~25% purchase a license key directly (converted from trial)
- ~10% churn without converting (trial expires, no invite)
- Invite-to-signup conversion rate: 40% (vs. ~2-3% for ads)
- Cost per acquired user: effectively $0 (vs. $3-8 CAC on social ads)
The invite system didn't just save ad spend — it created higher-quality users. Invited users have 2.3x higher retention than organic cold traffic, likely because they joined with social context and a friend already inside.
Building Your Own Invite System
If you want to replicate this architecture for your own product:
| Component | Tool | Why |
|---|---|---|
| API layer | FastAPI | Async, type-safe, great for real-time AI interactions |
| Database | PostgreSQL + SQLAlchemy | Reliable, ACID-compliant for invite dedup |
| Auth | JWT + OAuth2 | Stateless, works with Next.js frontend |
| Frontend | Next.js | SSR for SEO, API routes for lightweight endpoints |
| AI | DeepSeek / OpenAI | Character-driven conversations with long context |
The full stack is open for exploration — check out BiasSecret if you want to see it in action, or grab a license key on Gumroad to unlock unlimited play.
What I'd Do Differently Next Time
- Graph-based referral tracking — Neo4j would make "who invited who" queries O(1) instead of recursive SQL CTEs
- Referral leaderboards — Gamify the top inviters with exclusive AI companion skins
- Scheduled refill instead of on-demand — Batch invite generation every 24h reduces DB writes
Built with FastAPI, SQLAlchemy, DeepSeek AI, and Next.js. Follow me for more deep dives into indie AI product engineering.
Top comments (0)