← Back to work

Fitfo

Co-founder & CTO · creator fitness coaching

Train with the creators you already follow.

Role
Co-founder & CTO
Timeline
Apr 2026 – present
Team
8 engineers
Stack
React Native, Next.js, FastAPI, PostgreSQL, Redis, Stripe
Fitfo's public website: the headline “Train with the creators you already follow” above three phone screens of the app
30K+users
$200K+ARR
$500Kraised at $25M post
8engineers led

Overview

Fitfo is a coaching platform built around the creators people already follow. Paste a TikTok or Instagram link and it becomes a structured workout with every exercise and set. Run a creator's program as written, log weight, reps and rest, ask the in-workout coach about form, load or a swap, and see recovery by muscle group, session scores and leaderboards.

Creators with a combined 4M+ followers publish programs on the platform and get paid through Stripe Connect escrow payouts. The app is on the App Store and Google Play, and we've raised $500K at a $25M post-money valuation.

Under the hood it is a pnpm monorepo: an Expo app, a Next.js site that is the marketing page, coach dashboard, admin hub and MCP server at once, and a FastAPI monolith on four replicas behind HAProxy. Every number on this page is counted from the repo.

What I built

  • Own the system architecture end to end and lead a team of 8 engineers across mobile, web and API.
  • Designed the LLM systems that let one creator coach thousands of people: an import pipeline that refuses to invent exercises, a corpus RAG coach that cites the creator's own clips, and a program engine where the model only chooses exercises and code prescribes sets, reps and effort.
  • Lead the React Native, FastAPI, PostgreSQL and Stripe stack, including Stripe Connect escrow payouts with idempotent transfers and a two-stage webhook queue.
  • Hardened auth after a real SMS pumping attack: self-owned HMAC-stored OTP codes, a destination policy, Redis rate limits and a per-region verification-rate breaker.
  • Run production on one droplet with four API replicas, Celery on three queues, rolling restarts, success-only readiness caching and a database transport that sheds load instead of retrying into an outage.
  • Scaled the product to 30K+ users and $200K+ ARR.

By the numbers, counted from the repo

1,793API route handlers, 188 routers
476Postgres tables, 549 SQL files
13.8KAPI test functions, 819 files
5.2Kmobile test cases, 497 files
36Celery tasks on 3 queues
61scheduled jobs on one replica
368exercise variations, 8 splits
16custom Expo config plugins

Architecture

Mobile
Expo SDK 54 / React Native 0.81 on the new architecture, SQLite + AsyncStorage, RevenueCat, PostHog, iOS Live Activity, watchOS companion, share extension
Web
Next.js 16, React 19, Tailwind v4, TanStack Query, Stripe Connect, Agora live video, Monaco; 157 pages and 311 route handlers
API
FastAPI on Python 3.12, Pydantic, Celery, APScheduler, httpx; OpenAI chat, transcription and embeddings; Apify, Telnyx, Stripe, RevenueCat
Data
Supabase Postgres through PostgREST with the service role, RLS as the perimeter, pgvector HNSW at 1,536 dimensions, Valkey for queues, OTP codes and rate limits
Infra
DigitalOcean droplet, Cloudflare, nginx + HAProxy, systemd units, Vercel for web, Sentry, Locust load tests, Daytona sandboxes for full-suite runs

How it's built

  1. 01

    The import pipeline

    A link from a social app is hostile input: shortlinks, slideshows, carousels, missing audio, rate-limited scrapers. The pipeline extracts only what the evidence supports and fails in named ways.

    Accept, classify, enqueue

    • URLs are normalized and typed; TikTok shortlinks are expanded; photo posts skip oEmbed and go to a slideshow pipeline; Instagram carousels are routed to a separate importer that dedupes by source key so many users sharing one post trigger one compile.
    • The job row is written first, then published to Celery with retry disabled, so a broker outage returns 503 instead of hanging the request. The app polls on a pipeline-shaped cadence: 1.5 s at first, 10 s during transcription, 8 s during parsing, 5 s after, with a 180 s ceiling.

    Fetch and media

    • TikTok resolves through TikWM; Instagram prefers Instaloader and falls back to Apify. Every paid scrape passes a Redis-shared spend guard: $12 a day, 24 runs a day, 8 an hour, one in flight, results cached six hours. It exists because of a $54 invoice.
    • Media streams to disk with a 120 s timeout, an ffprobe audio check fails open, ffmpeg extracts mono 16 kHz MP3, and a poster frame goes to Supabase Storage.

    Transcribe, read frames, extract

    • Audio is transcribed with gpt-4o-mini-transcribe; transcripts under 30 characters count as weak and audio failure is non-fatal.
    • Frame OCR samples about one frame per second, capped at 20, and sends them in one low-detail vision call of roughly 1.7K image tokens.
    • Extraction builds an evidence object (transcript, on-screen text, caption) and calls chat completions at temperature 0 in JSON mode with five locked muscle groups and a rule that the model may never invent an exercise, set, rep or piece of equipment. A payload adapter translates legacy calls for reasoning models so call sites never changed.

    Retries and validation

    • Every OpenAI call retries up to 8 times on 429, 5xx and transport errors, honoring Retry-After with a 3 s floor and jitter; quota errors are never retried.
    • Exercise names must literally appear in the evidence; cue text is split into notes; title and muscle focus are re-derived from the surviving exercises; empty results carry a reason instead of a fabricated fallback.
  2. 02

    Creator corpus, in-workout coach and Creator Brain

    The coach cannot hallucinate the coach. Three systems share one corpus: the in-workout chat for members, a coach-side agent that writes in the creator's voice, and a client agent that proposes actions for coach approval.

    Ingestion

    • Five idempotent phases: discover (Apify profile crawl, unique per creator, platform and video id), transcribe, chunk (1 to 4 sentences, 200 to 500 characters, sponsor reads stripped), tag (locked muscle-group and goal enums that mirror SQL CHECK constraints) and embed (text-embedding-3-small into a 1,536-dimension pgvector column under an HNSW cosine index, model name stored per row).
    • Chunks carry an approval status and only approved chunks are retrievable; Creator Brain imports run as Celery jobs with four retries and 30 to 900 s backoff, stopping pagination after 20 consecutive known ids because TikTok pins posts.

    Retrieval and answering

    • A match RPC filters by creator, approval, muscle groups and goals in SQL before ranking, returns the top 8 by cosine similarity, and anything under 0.25 is dropped. Embeddings are never read into Python.
    • Generation runs at temperature 0.2 with 220 output tokens; every claim cites a numbered chunk, citations are clamped to what was retrieved, empty retrieval returns a fixed fallback, and the request is cut off at 55 s so the app fails instead of hanging.
    • The request schema bounds context to 40 exercises, 30 logged sets each and 60 recent workouts; the sheet sends the last two completed sets and the app-computed next set so set-aware questions get set-aware answers. A member is locked to one AI coach for 30 days.

    Creator Brain and the client agent

    • Section generation runs 5 to 6 probe queries per section, top-k 14, minimum similarity 0.18, at most 3 chunks per video so one rant cannot dominate. A separate verification pass flips a grounded flag on any unsupported numeric claim; under two evidence chunks the agent refuses.
    • The client agent reviews each client on check-ins and workouts with a 600 s debounce, proposes at most three actions that need one-click coach approval, and degrades to a deterministic path without an API key.
  3. 03

    The program engine

    Coaching programs are explainable because the model only ever chooses exercises. Splits, slots, sets, reps, effort and progression are code, tested against golden clients.

    Deterministic spec engine

    • A JSON spec (8 splits, 271 day-slot rows, 28 families, 368 variations) drives selection: days per week and style map to a split, slots are budgeted per day (5 to 6 short, 7 to 8 long, beginners capped at 6), Required slots survive and Optional slots drop first.
    • Pools filter by equipment, avoid-lists, active injuries at a fixed RIR, a difficulty ceiling, a 25-name niche blocklist and a 28-family preferred ordering; selection is deterministic via stable hashing so the same client always gets the same plan.
    • Protocol: 2 working sets, max 20 reps, double progression, compounds to 1 to 2 RIR then 0 to 1, isolation to technical failure, rest 150/120/75 s, with goal layers for hypertrophy, strength, powerbuilding, fat loss, recomp and athletic.

    Program Agent V2 and delivery

    • A 13-step pipeline behind five feature flags. The LLM returns exercise choices only, through a strict json_schema call with a 90 s timeout, against a prompt that forbids inventing exercises or slots, changing the split, or emitting sets, reps, RIR or tempo. A per-muscle volume ledger (18 catalog tokens into 12 groups) scores every week.
    • Plans release at a deterministic random time between 2 and 11 hours so a whole community never lands on the same minute; a coach approving first always wins. Program saves carry an expected version and a stale save is a 409 with both versions, never last-writer-wins.
  4. 04

    Payments: escrow payouts, webhooks, subscriptions

    Creators are paid from a platform account through Stripe Connect with escrow, and the mobile subscription runs on RevenueCat. The money code is built so no event is lost or double-applied.

    Separate charges and transfers with escrow

    • The customer pays 100% to the platform. A platform fee (default 30%) is frozen per payment and the coach pool is written as one allocation per coach, with duo partnerships fanned out at the frozen split.
    • After an 8-day escrow an hourly job creates Stripe transfers with an idempotency key per allocation; failed transfers retry at 0, 1, 4, 24 and 48 hours, then park. Refunds inside the window cancel allocations so no clawback ever runs. A destination-charge variant was merged once and intentionally removed.

    Two-stage webhook queue and dunning

    • The webhook verifies the Stripe signature, upserts the raw event on its id with duplicates ignored, returns 200 immediately, and falls back to synchronous processing only if the insert fails. Celery beat polls every 10 s; failures reschedule at 0, 30, 120, 600 and 3,600 s and go dead after five attempts with a critical ops record.
    • A failed invoice opens a 3-day grace; day-1/2/3 warnings send idempotently, then the entitlement pauses. A daily digest reports unresolved ops events, dead webhooks and dead transfers.

    RevenueCat and Pro gating

    • The RevenueCat webhook requires an exact-match shared authorization header and returns 503 when unconfigured rather than accepting unsigned traffic; events dedupe by last event id because retry timestamps shift. Coaching in-app-purchase failures return 500 so RevenueCat retries, while Pro-state failures are swallowed to avoid retry storms.
    • Server-side Pro is RevenueCat state, env-only bypass lists, creator-code trials, referral bonuses, or an active coaching entitlement with finish-the-month grace.
  5. 05

    Auth, OTP and the anti-pumping breaker

    On 2026-09-16 an SMS pumping attack sent roughly 4,000 codes of which about 200 were claimed. The response was to own the OTP path end to end and make every send pass a policy, a rate limit and a circuit breaker.

    Tokens and codes

    • Member sessions are HS256 JWTs with a 30-day expiry. Decoding rejects any token carrying a purpose claim, closing a bug where password-reset and OAuth-state tokens signed with the same secret satisfied roughly 375 member routes. Coach passwords use PBKDF2-HMAC-SHA256 at 200,000 iterations.
    • Codes are 5 digits for members and 6 for coaches, cryptographically random, stored only as HMAC-SHA256 in Redis keyed by E.164 with a 10-minute TTL, single use, 5 wrong guesses, sent over Telnyx with no automatic provider fallback so a failure never charges a second provider.

    Policy, limits and the breaker

    • Destination policy allows only home and allow-listed regions (US; CA, GB, AU) and rejects fixed-line, premium, toll-free, VoIP and pager numbers.
    • Per phone: 20 s resend cooldown, 8 per 15 minutes, 20 per hour; 60 per hour per IP; 120 per hour globally. Client IP comes from CF-Connecting-IP because every hop appends to X-Forwarded-For.
    • The new-number guard trips a region for 900 s when 25 or more new sends in 30 minutes verify under 25%, with a non-home hourly budget of 15 and a global burst cap of 20 a minute. Worst-case leak during an attack is about 25 US codes per 15 minutes and zero elsewhere, covered by 33 tests.

    Inbound signatures and Agent Connect

    • Telnyx Ed25519 over timestamp and body, Resend via svix, Calendly HMAC-SHA256 in constant time, SES with a trusted signing-certificate host check, Discord Ed25519, Stripe and Commas HMAC.
    • Agent Connect is an OAuth 2.1 authorization server (PKCE, exact redirect match, per-grant checks on every request) with 10 scopes, 53 routes and 23 MCP tools over the same handlers, plus a no-JavaScript HTML surface for browser-only assistants.
  6. 06

    Background work, infrastructure and observability

    Production is one DigitalOcean droplet running four API replicas, three Celery workers and a beat scheduler behind HAProxy and nginx, fronted by Cloudflare, against hosted Supabase Postgres.

    Queues and schedulers

    • 36 Celery tasks on imports, corpus and coaching queues with acks_late, prefetch 1 and reject-on-worker-lost; workers run concurrency 2, 200 tasks per child and a 180 s stop timeout so an in-flight generation finishes.
    • Celery beat is the single clock for queued periodic work; an APScheduler with 61 jobs runs on one replica only. Moving LLM generation out of the request process is what fixed the Plans tab timing out.

    Edge, health and database transport

    • nginx caches the version endpoint for 60 s and exercises for 5 minutes; HAProxy runs least-connections with 5 s readiness checks, 25,000 max connections and a 10 s slow start. Deploys are rolling restarts, one replica at a time.
    • Readiness checks Supabase and Redis with one pooled client each, caches successes for 10 s and never failures, written after four replicas times 5 s probes produced about 69,000 TLS handshakes a day at zero traffic.
    • The PostgREST transport is a hardened httpx client: 100 connections, 50 keep-alive, HTTP/1.1 only, transparent retry on connection errors before any bytes are sent, and no retry on pool timeout because that would amplify a Supabase slowdown into an outage. Pool timeout anywhere becomes a 503 with Retry-After.

    Observability and testing

    • One structured log line per request with status, latency, query count and slowest query, plus a Server-Timing header; hourly request, 5xx and login counts feed a public status page; Sentry at 5% traces with PII off; gzip for responses over 1 KB because program weeks compress about tenfold.
    • A Locust suite mirrors production with guardrails that cap runs against prod at 50 users. The full API suite runs in a Daytona sandbox: 7,995 tests and 988 subtests green after a repair pass that fixed three real app defects.
  7. 07

    Mobile engineering

    Built cache-first and offline-first, because a gym has bad Wi-Fi and a workout is an hour of data you cannot lose.

    Shell and caching

    • There is no navigation library: a 21,000-line state-driven shell renders screens by flags through a roughly 60-branch priority router, and coaching mode maps a different bottom bar onto the same state so every deep link and push route keeps working.
    • A read-through query cache coalesces in-flight requests, serves 30-second-fresh data without the network, shows stale data up to 24 hours, evicts beyond 200 entries, and records invalidation counters so an in-flight response cannot repopulate a key invalidated meanwhile.
    • The coach shell prefetches inbox, clients, updates, plan reviews, program library, payouts and leads after the first paint, then pre-mounts the tabs one paint apart; the inbox walk went from about 10 s to a snapshot after rooms plus the first page.

    Durable workout saves

    • The finish flow used to tear down the session and then POST; one bad gym-Wi-Fi moment destroyed an hour of logged sets with a reassuring message on top. Now the workout is written to a durable outbox first, removed only after server confirmation, and retried on launch, foreground, after history loads and when the network comes back.
    • Dedupe key is user id plus start time; the outbox holds 200 entries for 30 days (raised from 25 after a traveller hit the cap in two weeks); read-only mode, 401 and 5xx are retryable. History and lift PRs live in SQLite behind a write lock with an AsyncStorage fallback.

    Native targets and release

    • An iOS Live Activity shows the current exercise and a native rest countdown on the Lock Screen and Dynamic Island; updates are strictly ordered through an operation chain because concurrent ActivityKit updates can let an older state win.
    • A watchOS companion gets compact snapshots and sends set-logged, pause, resume and finish commands in a versioned envelope; when the watch starts a HealthKit session, ownership flips and the phone skips the duplicate write.
    • 16 custom config plugins mutate Expo's in-memory Xcode project for the widget and watch targets, strip background modes after an App Review rejection, and pin version strings; a local archive script exists because EAS cannot build those targets. The version gate fails closed to the bundled floor when the server is unreachable.
  8. 08

    Data and security posture

    A single Supabase project reached through PostgREST with the service role: the API is the policy engine and RLS is the perimeter that keeps the internet out.

    Schema and ledgers

    • 549 SQL files define 476 tables (189 coaching tables), 204 functions, 99 triggers and 18 views; services probe for missing columns and degrade with a run-migration-N 503.
    • Money and points move through append-only ledgers: a points ledger idempotent on profile and event key with a daily rollup row locked to serialize cap checks; escrow allocations and payout requests as explicit rows; a Stripe event table for dedupe.

    RLS as a perimeter

    • RLS enabled with zero policies means deny-all for the anonymous and authenticated roles. A hardening pass closed 39 tables the anon role could read or write, pinned search_path on 60 functions, revoked execute on 70 RPCs, and added event triggers so new tables and functions are locked at creation. An audit script exits non-zero on regression.
    • An idempotency middleware keyed on a client header caches only 2xx responses, releases the claim on 500 and recomputes 4xx; creator import jobs are limited to one active per creator by a partial unique index.

Decisions

  1. 01

    LLM selects, code prescribes

    Both program engines keep sets, reps and effort deterministic and let the model only pick exercises from candidate lists, so programs are explainable and testable against golden clients.

  2. 02

    Refuse rather than invent

    The parser drops exercises not in the evidence, the coach returns a fixed fallback below 0.25 similarity, and Creator Brain refuses under two chunks. Verification is a separate model pass because one call asked to write and self-audit reliably says yes.

  3. 03

    Imports as the on-ramp

    Most users arrive from a creator's video. Turning that link into a runnable workout removes the blank-page problem on day one.

  4. 04

    Escrow, not destination charges

    Separate charges and transfers with an 8-day hold avoid clawbacks on refunds, at the cost of an hourly release worker and allocation bookkeeping.

  5. 05

    Two-stage webhooks

    Return 200 to Stripe immediately and process on Celery with bounded backoff and a dead state. Complexity traded for never losing or double-applying an event.

  6. 06

    Fail fast on pool exhaustion

    A database pool timeout is never retried and becomes a 503 with Retry-After, because retries turn a Supabase slowdown into a Fitfo-wide outage. Operators are told restarting is not the fix.

  7. 07

    Own the OTP path

    After the pumping incident, codes moved to self-owned HMAC-stored secrets over Telnyx with no automatic provider fallback, so a failure never charges a second provider.

  8. 08

    Queue the slow work, cache the reads

    Background jobs run on Celery behind Valkey so the app stays fast during creator launches; the mobile app trades 30, 15 and 3 second staleness windows for zero-spinner warm screens.

Edge cases the code handles

  • Spoofable client IP: the first X-Forwarded-For entry was attacker-controlled, so rate limits key on CF-Connecting-IP.
  • The breaker window excludes the current minute so a fresh burst has time to be typed in; login codes for existing numbers bypass the new-number guard so real users keep getting in during an attack.
  • Supabase's edge closes idle sockets; a protocol error before any bytes are sent is retried safely, even for inserts.
  • TikTok pins videos, so incremental crawls stop after 20 consecutive known ids, not the first.
  • RevenueCat retries shift timestamps, so dedupe uses the last event id; Stripe Decimal values round-trip through json.dumps with default str before landing in jsonb.
  • Daily points caps serialize through an insert-on-conflict that takes the row lock; timezone changes are rate-limited because the daily bucket keys off profile timezone.
  • Late responses after a cache key change are discarded by a generation counter; Live Activity rest end times are converted from monotonic to wall clock.
  • The web middleware matcher uses a boundary group so a coach whose handle starts with api or admin is not silently excluded from the dynamic handle route.
Rohan Kulkarni