Work
Fitness LIVE IN PRODUCTION

We replaced a CrossFit gym's rented software with one operating system we built and still run

Persistence Athletics paid monthly to run their gym on Wodify. We swapped it for one system: a custom Stripe billing engine, a member portal, a native iOS app, an AI coach, and a programming engine. We built it, then we run it.

24branded email flows + a custom Stripe billing engine, all running in production
Book a call Visit it live

The problem

Persistence Athletics is a CrossFit gym in Belltown, Seattle. Like most gyms, they rented their whole operation from off-the-shelf software (Wodify): memberships, billing, class booking, programming, member communication. The problem with renting is you bend your business to fit the tool. Wodify bills each member on their signup anniversary, but this gym bills everyone together on Sunday night, with reminders on Friday and pauses that only ever start next Monday. That one mismatch touches everything. On top of it, members had no real app, the coach had no AI help answering the same nutrition and workout questions over and over, and the class programming lived in a separate place from where members actually trained. We were asked to stop renting and own the whole thing instead: one system that fits how this gym actually works, that we build and then operate day to day.

What we built

The modules of the system.

01

Custom Stripe billing engine

The core of the gym, and the reason off-the-shelf software didn't fit. Stripe is used only as a card vault. It does not own the billing cycle. A Sunday-night cron is the only thing that charges cards: it applies any pending plan change, future-dated pause, credit, complimentary status or coupon, re-resolves the plan price plus Washington tax (10.55% Seattle sales + 0.471% B&O, combined into one 11.021% rate), creates a one-off Stripe invoice, pays it, and advances next_billing_date. Friday sends renewal reminders. A daily pass expires drop-ins and punch cards and cancels members who hit the four-week pause cap. Pause, resume, change-plan, auto-renew, payment-method gate, credits, gift days and one-off charges all run through it.

02

Member portal

A full member home at app.persistenceathletics.com. Members see today's workout with per-exercise and per-set logging, book classes, log food against macro goals with progress rings, track weight and performance PRs, view attendance, the gym leaderboard, community birthdays and recent PRs, in-app notifications, a document library, WHOOP recovery data, blood-work and biomarker dashboards, and manage their own membership (subscribe, upgrade, pause) and billing. Access is gated by tier: lead, trial, remote, member, paused, each unlocking different features.

03

Native iOS app

A native app on TestFlight, built with Expo SDK 54 and React Native. It wraps the portal in a native shell with Face ID biometric lock, push-notification registration, pull-to-refresh, native back/swipe gestures and an offline error screen. Dev builds point at the dev environment and production builds at the live portal, so the gym can test new code on their phones before it reaches members.

04

PA Bot AI coach

An AI coaching assistant members chat with inside the portal and over WhatsApp. It runs a tool-calling loop with 30-plus tools: it can log workouts, log food (including from a photo), record weight, performance, biomarkers and blood-work results, read lab reports, pull the leaderboard, and sync WHOOP. The system prompt is tier-aware, so a lead only gets gym info while a paying member gets the full toolset. Daily message limits scale by tier.

05

EDSO programming engine

The strength-and-conditioning programming the gym actually runs, served straight into the member's workout view. It serves daily-undulating periodization with tiered work (primer, Olympic lifting, strength, accessories, burnout, metcon, Zone 2), a 579-entry movement-video library matched to exercises by fuzzy lookup, suggested weights from RPE and 1RM, and supplemental blocks like Murph prep. Coaches can swap days, hide or replace movements, all stored as overrides that survive a re-sync.

06

Coach admin

The back office. Members, memberships and plans, invoicing, class scheduling, programs, financial reports (MRR, churn, revenue, unpaid), communications (banners, mass notifications, email campaigns), appointments, coupons, events, waivers, a coaching playbook and SOPs, plus a per-member view with attendance, performance, chat history, biomarkers and WHOOP.

07

24 branded email flows

Every transactional email the gym sends, written in the coach's voice (no corporate tone, no em dashes) and wrapped in one branded shell. Billing receipts, failed payments, upcoming charges; welcome, trial-welcome, trial-ending and waiver confirmations; password and profile changes; membership confirmed, changed, paused, resumed, cancelled, expired; class booking, waitlist-promoted, class-cancelled; birthday and milestone badges. Templates read stored values from the database rather than recomputing dates, after a bug where a receipt showed the wrong next-charge date.

08

Content cataloger

An automated pipeline for the gym's class footage. FFmpeg extracts audio from dual-camera recordings, local Whisper large-v3 transcribes both cameras, Claude reads the transcripts and builds a clip catalog, a script cuts clips from both cameras plus B-roll, and a branded Excel workbook routes work to a four-editor team. Turns hours of raw footage into ready-to-edit social clips.

How it fits together

Everything runs on one VPS behind a Caddy reverse proxy with TLS, with a firewall that only exposes ports 22, 80 and 443. Every app binds to localhost and is reachable only through Caddy. The member portal and coach admin are one Next.js 15 / React 19 app (app.persistenceathletics.com on port 3003), with an identical dev copy on a separate environment and database (dev.persistenceathletics.com on 3013) that the native app's dev builds hit, so every change is tested on a phone before members see it. A shared SQLite database (better-sqlite3, WAL mode) backs everything, with typed query modules in a shared package. The AI coach runs as a separate service: the portal never calls the model directly, it proxies through PA Bot on localhost, which holds the full 30-plus-tool agent and also handles WhatsApp. The EDSO programming engine runs as its own service and the portal proxies workout data from it, caching all 52 weeks locally so member workout loads are instant. Stripe is the card vault only; SendGrid sends the email; GoHighLevel handles CRM and SMS. Billing, reminders and cleanup run on cron, not on Stripe's schedule. Deploys are manual and deliberate (one authoritative script, prod and dev synced together) after CI was turned off for being flaky. The native app is a separate Expo / React Native repo.

Next.js 15React 19TypeScriptNode.jsbetter-sqlite3 (SQLite, WAL)StripeSendGridExpo SDK 54React NativeRechartsTailwind CSSCaddysystemdcronOpenRouter (Claude)GoHighLevelWHOOP APIFFmpeg + Whisper large-v3

Under the hood

The decisions that mattered.

Why we built our own billing engine instead of using Stripe subscriptions

The gym bills everyone on Sunday night for the upcoming Mon-Sun week, sends reminders Friday, and only ever starts a pause next Monday. Stripe subscriptions bill on each member's signup anniversary, which is the opposite. So we made one rule and never broke it: Stripe is a card vault and nothing else. It stores customers and payment methods. It does not run billing cycles. A Sunday cron is the only thing that charges a card. It creates a one-off Stripe invoice and pays it; there is never a stripe.subscriptions.create call anywhere. This rule was earned. Early on, beta testers got billed twice, once by Stripe-native subscriptions created at checkout and once by our cron. We cancelled every native subscription and made checkout coerce subscription mode to setup mode automatically. The discipline is encoded as a skill the codebase reads before anyone touches billing.

Forward-looking billing: charge for the week you're entering, not the one ending

Because the Sunday bill pays for the upcoming week, future-dated changes have to be applied to the cycle being billed, not the one ending today. Two members downgraded from a 3x to a 2x plan; the downgrade correctly set the change to take effect the next Monday. But the cron checked whether the change date was on or before today, saw it wasn't yet, and billed both at the old higher rate, overcharging each by 11 dollars and change. The fix changed the check from 'on or before today' to 'within the upcoming cycle window.' We turned it into a standing rule: any new future-dated field on a membership (pause start, plan-change date, cancel date) must be applied and re-priced before the charge fires, with a test that downgrades on a Friday and confirms the Sunday cron bills the new rate.

Templates read the database, they never recompute

A receipt email once told members their next charge was May 10 when their actual next-billing date in the database said May 3. The template had recomputed the date by adding seven days to the period end instead of reading the value already stored on the row. The same shape of bug existed in three other places: trial reminders, a class check-in timezone, and a signup trial-end date. We made it an invariant: if a value like next-billing-date or trial-end is already on the row, the email and the UI must read it, never re-derive it from the current time plus an offset. This is the kind of rule that only comes from operating the thing in production, watching a member get a wrong date, and fixing the class of bug rather than the one instance.

One app, two synced environments, so the gym tests on their phone first

The native iOS app is a thin React Native shell around the portal. Production builds load the live portal; dev builds load a separate dev environment with its own database and Stripe sandbox keys. That means the coach can run new code on his actual phone, against fake money, before any member sees it. The cost is that every deploy has to sync both the production and the dev standalone builds, or the phone shows stale code. We made the deploy a single authoritative script that builds once, copies static assets into both prod and dev standalone outputs, restarts both services, and smoke-tests both domains. We deliberately turned off deploy-on-push because the automated path was flaky even when prod went green; manual is the one source of truth.

Release log

What we shipped.

Mar 2026

Custom Stripe billing engine built: dev/prod separation, Sunday billing cron, Friday reminders, daily cleanup, pause/resume, payment-method gate, communications hub. 24 branded email flows and a shared tax system shipped alongside.

Apr 2026

Beta launch to 12 testers. Native iOS app scaffolded with Expo and pushed live to TestFlight. Billing forensics: fixed a double-tax email, double-billing from Stripe-native subscriptions, Stripe Link payment support, and the combined 11.021% Washington tax rate. Per-exercise suggested weights and a merged performance/logbook view added.

May 2026

Per-set workout logging with tonnage roll-up by movement pattern. Open-gym and S&C class overlap booking. The forward-looking billing fix for future-dated plan changes, and deferred status flips so paused members keep their already-paid week.

The outcome

Persistence Athletics no longer rents its operation. Billing, memberships, class booking, programming, member communication, the AI coach, the native app and the content pipeline are one system we built and still run, fitted to how this gym actually works (Sunday billing, Friday reminders, Monday pauses) instead of the other way around. It is live in production at persistenceathletics.com, app.persistenceathletics.com, the workout and program subdomains, and on TestFlight, serving real members and charging real cards every Sunday. The billing engine has been hardened through real incidents (double-billing, double-tax, wrong-date receipts, forward-dated downgrades), each one fixed at the class-of-bug level and encoded as a rule the codebase enforces.

Put your business on autopilot