We replaced Wodify with a gym OS we built and run
Persistence Athletics fired its gym software and asked us to build the replacement. Here is the real story: a custom Stripe billing engine that bills everyone on Sunday, 24 branded email flows, a native iOS app on TestFlight, the EDSO programming engine, and an AI coach assistant. The architecture, the decisions, and the bugs we paid for.
The premise: stop renting your gym's software
Persistence Athletics is a CrossFit gym in Belltown, Seattle. Like most gyms, it ran on Wodify. Membership, billing, class booking, programming, member communication, all of it lived inside one vendor's product, and none of it quite fit how the gym actually operated. The owner coaches there. He records every class with two cameras and a wireless mic. He bills the whole roster on Sunday night, not on each member's signup anniversary. Off-the-shelf software fought him on both.
So we did what we do. We did not bolt on a plugin or buy another tool. We built the gym a single operating system and now we run it. One codebase, one database, one place where membership, billing, programming, class booking, and member messaging all live. The result is live today at app.persistenceathletics.com, with a native iOS app on TestFlight and an AI coach assistant answering members over WhatsApp. Here is how it actually got built.
Why we wrote our own billing engine
The hardest decision came first, and it was about money. Stripe has a perfectly good subscriptions product. We did not use it. The reason is one business rule: this gym bills everyone on Sunday night. Stripe subscriptions bill on each customer's anniversary date, the day they signed up. There is no clean way to make Stripe's scheduler bill a whole roster on the same evening. So we inverted the relationship. Our cron decides when to bill. Stripe is just the card processor.
The architecture is simple to say and full of sharp edges to build:
- Our weekly cron runs Sunday night, finds every membership due, and creates a Stripe invoice per member.
- We finalize and explicitly charge each invoice ourselves. We do not let Stripe auto-advance.
- Stripe webhooks come back and confirm success or failure, and we update our own database, which is the source of truth for everything the member sees.
- A Friday cron sends renewal reminders. A daily cleanup cron expires drop-ins and punch cards and auto-cancels memberships paused past their limit.
The membership record carries the state this needs: next_billing_date, auto_renew, failed_payment_count, and a set of pause fields (pause_start, pause_until, pause_weeks, with a hard cap of four). Pause only applies to next week, never the current one, and resuming mid-week charges the full rate. Those are the gym's rules, not Stripe's, which is exactly why they had to live in our code.
A custom billing engine means you own every failure mode that a vendor would normally hide from you. We paid for that education. A member was charged 95.76 dollars instead of 86.98 because tax got added twice, once as a Stripe line item and once again by our own invoice helper applying a tax rate on a subtotal that already included it. We had two of five charge paths that forgot tax entirely, so members on different flows saw different totals. A cron written as '0 6 on Monday' fired on the wrong day because the server runs UTC and nobody did the timezone math. Invoices showed 'Invalid Date' in the portal because we omitted due_date and forgot to stamp paid_at. One member paid through Stripe Link instead of a card, and our default-payment-method lookup only searched for cards, so the next charge had nothing to bill.
Every one of those became a rule we now apply to every billing system we build. Pick one tax strategy, never both. One shared charge helper that all paths call, so tax can never be inconsistent. Disable Stripe auto-retry on cron-driven billing so the gym controls the retry schedule, not Stripe firing surprise decline emails days later. Always set the customer's default payment method after any card is added, and search all payment method types, not just cards. The combined Seattle rate landed at 11.021 percent (10.55 sales plus 0.471 B and O) as a single line item, and every invoice now stores subtotal, tax, and total separately so the member always sees the breakdown.
24 emails that sound like a coach, not a corporation
A gym OS lives or dies on the messages it sends. We built 24 branded transactional email templates covering billing (receipt, failed, upcoming, update card), onboarding (welcome, trial, waiver), account, the full membership lifecycle (confirmed, plan changed, paused, resumed, cancelled, expired), class booking, and community (birthday, milestone). They run through SendGrid on a verified sending domain.
Two details mattered more than the count. First, we dropped the image logo entirely after it refused to render in real email clients and rebuilt the header as styled text, green Persistence over dark Athletics, with a green accent bar. Second, the tone. These read like a coach talking to an athlete, personal and direct, with no corporate filler and no em dashes anywhere. A payment receipt from your gym should not sound like a SaaS invoice.
EDSO: programming as a rules engine, not a spreadsheet
The programming side runs on EDSO, a rules-based strength and conditioning engine that generates the gym's daily training. This is not a static PDF that someone hand-edits. It is scheme tables and validation. We rebuilt the periodization model around a T3 primer system and DUP, daily undulating periodization, with 24 rep-scheme variants across eight volumes and three intensity bands. When we changed the model we ran a retroactive migration across 52 weeks and 173 days of programming in PostgreSQL, then validated all 173 day-configs against rep math and intensity-chain rules. All 173 passed before anything shipped.
On top of EDSO sits a logbook and suggested-weights feature that makes the programming personal. We store each member's training maxes and the gym's actual band library (eight Rogue Monster bands), then a scheme parser reads the percentages out of each day's prescription and computes a suggested working weight rounded to five pounds. For RPE-based work it maps RPE to a percentage (7 is about 86 percent, 10 is 100) and shows what the member lifted last time. We backfilled roughly 70 members' maxes by inferring them from their performance history so the feature was useful on day one, not empty.
PA Bot and the dual-environment problem
PA Bot is the AI coach assistant. Members talk to it in the portal and over WhatsApp and SMS. It can log a max, look one up, suggest a weight, and file feedback or bug reports straight into a coach review queue, all in natural language. The system prompt has one hard rule we enforce: never claim a tool succeeded when it did not. An AI assistant that lies about whether it logged your lift is worse than no assistant.
The interesting engineering was keeping a closed beta safe. We launched to 12 testers, and we needed the bot to operate against both the production and development databases without ever crossing the streams. We used AsyncLocalStorage with a Proxy over the database connections so the same code path could resolve to whichever environment a given request belonged to. The portal sends an explicit env flag. Inbound WhatsApp and SMS route by a beta_tester flag on the member record. The crons and pollers iterate both environments with opposite filters, so production skips beta testers and dev only processes them. One bot, two worlds, no contamination.
The native app: ship the wrapper, earn the rewrite
Members wanted an app on their phone, with Face ID and push notifications. We had a choice: rewrite the whole portal in React Native, or wrap the existing web app. We shipped the wrapper. The native app is built on Expo SDK 54 and React Native, loading the member portal in a hardened WebView with a biometric gate, push token registration, native swipe-back, pull-to-refresh, and a real error screen with retry. The production build went through EAS, was signed with proper Apple distribution credentials, and is live on TestFlight today.
This is a deliberate altitude call. A WebView wrapper got a real, installable, Face-ID-gated app into testers' hands in a day instead of a quarter, and every portal feature we ship to the web shows up in the app for free. The full native rewrite is a thing you earn once you know which screens deserve it, not a tax you pay up front.
The part nobody sees: the content pipeline
There is one more piece that is pure operations leverage. The owner films every class. Four editors handle the footage. That used to mean someone manually scrubbing hours of video looking for usable moments. We automated the front of that pipeline: FFmpeg pulls audio from the dual-camera recordings, a local Whisper large-v3 model transcribes both cameras, Claude reads the transcripts and produces a catalog of the good moments, and a script cuts clips from both angles plus B-roll from the gaps. The first run turned one session into 53 catalogued moments and 152 clips, then handed them to the editing team with assignments already attached. The gym makes content; the software does the scrubbing.
What 'we build it, then we run it' actually means
None of this is a demo. The billing cron charges real cards every Sunday. The emails land in real inboxes. The bot answers real members on WhatsApp. When tax was double-charged, a real person paid nine dollars too much, and we found it and fixed it because we operate the thing, not just deliver it. That is the whole model. We embed, we build the operating system the business actually needs, and then we keep running it while it runs the business.
Persistence Athletics fired its gym software and got a system shaped exactly like its gym. That is the difference between renting software and owning your operations. Your business, on autopilot.
Wondering what one system would replace for you?
