The problem
Every fitness app needs a catalog of exercises. It sounds like a solved problem. It isn't.
What's actually out there is scraped CSVs with inconsistent naming, paid APIs whose free tier dies at a hundred requests, and datasets where "Barbell Bench Press" and "Bench Press (Barbell)" are two separate rows with two different muscle mappings. I hit this while building GymPlanner and realised I was about to hand-maintain a spreadsheet forever.
So I stopped and built the catalog properly. Then it turned into something bigger than a catalog.
Try it live
This isn't a mock. Every request below hits api.harshitbishnoi.dev in production and returns whatever it actually says.
https://api.harshitbishnoi.dev/exercises?limit=3Running…The constraint that shaped everything
I set one rule up front: a client should be able to sync the entire catalog once and then work offline.
That single decision cascaded through the whole design. It meant the API had to be read-heavy, aggressively cacheable, and boring — which is exactly the right shape for a catalog. It meant no clever per-user filtering on the server. It meant the response format had to be stable enough that a client could cache it for weeks without breaking.
Most of my design decisions after that were just consequences of this one.
Modelling exercises is harder than it looks
An exercise isn't a name and a muscle. It has:
- a primary muscle, plus several secondary ones
- equipment, which changes the exercise's identity — a dumbbell press is genuinely not a barbell press
- a movement pattern — push, pull, hinge, squat, carry
- and a dozen naming variants that all mean the same thing
Get this schema wrong and every app built on top of it inherits your mess. That's the part I actually cared about.
Sync is the hard part
Handing someone a list of exercises is easy. Letting them keep that list correct, forever, without re-downloading it — that's the actual engineering.
GET /sync/exercises returns changes since a timestamp. Which sounds simple
until you deal with the parts that bite:
Deletions have to be visible. A record that vanishes from a response is indistinguishable from one that didn't change. So deletions come back as tombstones — explicit "this is gone" / "this is deprecated" events — and the client applies them rather than inferring them.
A write during a sync must not be lost. Every page of a single sync reports
the same latestChangeAt, captured before the first page was read. So a record
written halfway through your sync doesn't slip into a gap between pages — it
just arrives on your next run. Getting this wrong gives you silent, permanent
data loss on the client, which is the worst kind of bug: invisible and
unrecoverable.
Pagination is over change events, not records. So limit bounds events, and
a full page can return fewer exercises than the limit. Clients page on
hasMore, never on exercises.length.
Then it stopped being just an API
Once it was good, the obvious question was whether anyone else could use it. That turned a project into a product, and the product work was its own education:
- API keys and metered access. Every request is authenticated, counted
against a quota, and answered with
X-RateLimit-Remainingso a client can see where it stands before it gets cut off. - Usage tiers, with premium endpoints gated by plan.
- Billing, provider-neutral by design and wired to Lemon Squeezy first: checkout, signature-verified webhooks, idempotent delivery, automatic upgrade on activation and downgrade on cancellation, expiry, pause, or failed payment. Built and tested end to end; it's waiting on Lemon Squeezy's merchant verification, not on code. Which is why the provider sits behind an interface — if that approval never lands, swapping in Stripe or Paddle is a new adapter, not a rewrite.
- A developer dashboard for keys and usage, and a docs site, both drawing from one shared design system so they read as one product.

What I'd defend
Errors are RFC 9457. Every failure returns application/problem+json with a
machine-readable type, a stable code, and a requestId. A developer hitting
a wall can quote that id back at me and I can find the exact log line. Try the
"trigger an error" button above — that's a real 404 from production.
Validate at the edge, then trust the types. Zod parses every input at the route boundary. Past that line the types are real and I stop writing defensive checks. A malformed request fails in one predictable place instead of five unpredictable ones.
Test the API, not the functions. 116 tests across 25 files, Vitest and Supertest, hitting real routes and asserting real responses. I don't unit-test helpers that exist to serve exactly one endpoint — if the endpoint is right, the helper is right. The tests document the contract, which is the only thing a consumer cares about.
What I got wrong
The first schema stored muscles as a plain string column. It worked for about
a day — right up until I wanted "every exercise that hits the posterior chain"
and realised I'd have to do it with LIKE. Migrating to a proper join table cost
two hours I could have saved with ten minutes of thinking. Model the
relationship, not the display string.
include_deprecated=true returns deprecated records in both exercises and
tombstones. So a caller who explicitly asked for deprecated data still has to
filter it back out of the tombstone array. It's a wart. I know it's a wart. It's
in a success-response shape, so fixing it is a breaking change — which is exactly
the lesson: a bad public contract is expensive in a way a bad internal function
never is. Get the shape right before anyone depends on it.
None of my migrations have a down. Every one is a one-way door. It's fine
right up until the day it isn't, and by then it's a project-wide problem rather
than a five-minute one.
Webhooks are processed synchronously, with no reconciliation job. If Lemon Squeezy's delivery fails permanently, nothing repairs the state — I'd find out when a customer emailed me. It's one database write today so it holds, but I've written down the failure mode rather than pretending it isn't there.
Where it's going
Version one is deliberately narrow: the public catalog and sync. Private user-created exercises, food data, and workout generation are all sitting in a notes file, and they can stay there until the catalog is genuinely solid. Shipping a narrow thing that works beats shipping a broad thing that doesn't.
The API is live at api.harshitbishnoi.dev, with docs and a developer dashboard.