DEV Community

kk mors
kk mors

Posted on

I Built an Invite-Only AI Dating Sim That Grew Without Ads — Here's the Growth Engine

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)
Enter fullscreen mode Exit fullscreen mode

Key decisions here:

  • secrets module instead of random — cryptographically secure, no predictability
  • Ambiguous character removal — users typing codes on mobile won't confuse 0 vs O
  • 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)
Enter fullscreen mode Exit fullscreen mode

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."}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Alice gets +1 invite (refill)
  2. Bob gets +1 invite (refill)
  3. Bob now has 6 invites to share
  4. 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
...
Enter fullscreen mode Exit fullscreen mode

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

  1. Graph-based referral tracking — Neo4j would make "who invited who" queries O(1) instead of recursive SQL CTEs
  2. Referral leaderboards — Gamify the top inviters with exclusive AI companion skins
  3. 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)