A gym check-in that decides at the door
How Zenthos Gym turns one printed QR code into a membership check, a live front desk feed and a fair start date for every member.



Most gyms in Lagos still check members in with a card, a register book or a receptionist’s memory. Queues form at 6 am, expired members slip through when the desk is busy, and nobody can say how many people actually trained this month.
Zenthos Gym replaces all of that with one printed code at the entrance. This is how it works, and why most of the logic lives in the database rather than the app.
One code per branch, not per member
The first instinct is to give every member their own QR code and have the desk scan it. That needs a scanner at the desk, someone free to use it, and it fails the moment the receptionist steps away.
So I flipped it. Each branch prints one entrance code. It points to /checkin?b=<branch id>. Members scan it with their own phone camera, which they already have in their hand, and the app does the rest. The desk never touches anything. It just watches.
The decision is made in one place
When a signed-in member opens that link, the app calls a single database function, check_in, with the branch. Everything that decides whether they can walk in happens inside it, in one transaction:
if p.expires_at is null then k := 'no_membership';
elsif visited_today and not started then k := 'duplicate';
elsif p.expires_at >= now() then k := 'valid';
else k := 'expired';
end if;
insert into check_ins (user_id, branch_id, kind) values (uid, b, k);
Four outcomes, and every scan is recorded with its branch, including the ones that fail. That record is what later powers visits per branch, busy hours and the manager’s dashboard.
Keeping this in the database matters. The rule cannot be skipped by an old version of the app, a slow network or someone poking at the browser. The phone only shows what the database decided.
“Today” means the gym’s today
“Already checked in today” sounds simple until you remember servers run on UTC. Lagos is an hour ahead, so a member scanning at 12:30 am would land in the wrong day.
The function works out the start of the day in the gym’s own time zone, stored in its settings, before checking for an earlier visit:
day_start := date_trunc('day', now() at time zone s.timezone) at time zone s.timezone;
The same time zone decides when a membership ends and whether today is a member’s birthday, which earns them a greeting at the door.
Memberships start on the first scan
A member who pays on Friday night but first trains on Monday should not lose three days. So a paid plan is held as pending days, and the clock only starts when they first scan in. That first scan turns pending days into an end date in the gym’s time zone and sends them a “membership started” notification.
It is a small rule, but it is the kind of fairness members notice, and it stops the desk from arguing about dates.
Limits for the clever ones
A code on the wall is public. Anyone can open the link. That is fine, because the function only checks in the signed-in member who opened it. Someone without an account sees a welcome screen that invites them to join, not an error.
Rapid repeat scans are capped too. More than six scans in ten minutes is refused, which keeps the visit history honest and stops anyone from hammering the system.
The desk sees it as it happens
The front desk screen subscribes to new rows in check_ins through Supabase Realtime. Each arrival appears instantly with the member’s photo, name and days left.
Each outcome also has its own sound, so the receptionist knows the result without looking up. On the member’s phone, an expired scan buzzes in a different pattern from a valid one.
One membership, every branch
Because the branch travels with the scan, a member can train at any branch on the same membership, and the gym still knows exactly where every visit happened. Managers see revenue, renewals due and visits for each branch in one view.
What I took from it
- Put the scanner in the member’s hand. Removing hardware from the desk removed most of the problems.
- Keep the rule where it cannot be bypassed. The database decides and the app explains.
- Record failures, not just successes. The expired scans are the most useful data the gym has.
- Respect local time. Any rule with “today” in it needs the business’s time zone, not the server’s.