A follow-up to my previous post on connecting a ZK fingerprint device to Rails. This is the upgrade story — what broke, what I rebuilt, and what the system looks like now.
👉 Read the original post first: Connecting a Biometric Fingerprint Device to a Rails Web App Using Python
The Problem with "Good Enough"
When I published the first biometric post, the system worked. The Python bridge polled the device, forwarded punches to Rails, attendance appeared on the dashboard.
But "worked" and "production-ready" are different things.
Here's what the system looked like in practice after a few weeks of real gym use:
- The bridge only ran when staff remembered to start it. They didn't always remember.
- The bridge sent every historical punch on every restart. January records were being re-sent in March.
-
Expired members could still walk in. The
DENIEDlabel in the dashboard was cosmetic — the door opened anyway. - Enrolling a new member required two separate manual operations — entering them in the software, then going to the device and registering their fingerprint, then manually creating a mapping record in the database.
- The gym's spare Android phone running the bridge had adware that would randomly kill background processes.
- ADMS (the device's built-in cloud push protocol) wouldn't connect — SSL/HTTP mismatch between the ZK firmware and Cloudflare/Render.
None of these were bugs in the traditional sense. The code did what I wrote it to do. The problem was the gap between what the code did and what the business actually needed.
This post is about closing that gap.
What I Built (The Upgrade Summary)
| Feature | Before | After |
|---|---|---|
| Bridge startup | Manual | Auto-starts silently on Windows boot via VBScript |
| Historical re-sends | Happened on every restart | Filtered by date.today() — today's punches only |
| Expired member gate access | Door opened, just logged DENIED | Finger template deleted from device — door physically stays closed |
| Member enrollment | 3 manual steps across 2 systems | Single "Enroll Fingerprint" button in member profile |
| Bridge reliability | Gym laptop (unreliable, staff-dependent) | Dual: laptop (primary) + Android phone (backup) |
| Cloudflare/Render SSL | ADMS blocked by SSL mismatch | Flexible SSL on Cloudflare — HTTP from device, HTTPS to Render |
| Finger template backup | None | Templates stored as JSON in DB before deletion |
| Auto-restore on renewal | None | Sync script restores templates from DB on next run |
Part 1: Fixing the "Today Only" Problem
The original bridge used an in-memory last_sent set to deduplicate. This worked within a single session but had a critical flaw: every time the script restarted, the set was empty, so all historical records were re-sent to Rails.
The fix is one line, but it changes everything:
# bridge.py
from datetime import date
while True:
today = date.today() # re-evaluated every loop iteration
attendances = conn.get_attendance()
device_sn = conn.get_serialnumber()
for att in attendances:
# Only process today's records
if att.timestamp.date() != today:
continue
key = f"{att.user_id}-{att.timestamp}"
if key in last_sent:
continue
payload = {
"compcode": COMP_CODE,
"user_id": att.user_id,
"timestamp": att.timestamp.strftime("%Y-%m-%d %H:%M:%S"),
"device_sn": device_sn
}
send_to_rails(payload)
last_sent.add(key)
time.sleep(POLL_INTERVAL_SECONDS)
Two important details:
-
date.today()is called inside the loop, not outside it. This means if the script runs past midnight, it automatically shifts to the new date without a restart. - The deduplication set
last_sentstill prevents duplicate sends within a session. Rails-side deduplication handles the cross-session case.
The result: restart the bridge at any point during the day and it picks up exactly from today's records. No January punches appearing in May.
Part 2: Auto-Start on Windows — The Silent Background Process
The original .bat file approach had a visible terminal window. Staff would close it. Attendance would stop. Nobody would notice until the owner called.
The fix is a .vbs file placed in the Windows Startup folder:
' start_bridge.vbs
' Place in: shell:startup
Set objShell = CreateObject("WScript.Shell")
objShell.Run "cmd /c cd C:\Users\Admin\Desktop\biometric_bridge && python bridge.py", 0, False
objShell.Run "cmd /c cd C:\Users\Admin\Desktop\biometric_bridge && python enroll_api.py", 0, False
The 0 as the third parameter means hidden window — no terminal, no visible process. Staff can't accidentally close what they can't see.
Both the attendance bridge and the enrollment API (covered later) start silently on every boot.
To verify it's running without a terminal window, check Task Manager → Details tab → look for python.exe. If it's there, the bridge is alive.
Preventing Sleep from Killing the Process
Windows sleep/hibernate will kill background processes even with the VBScript approach. Set these before deploying:
Settings → System → Power & Sleep
Screen: Never
Sleep: Never
Control Panel → Power Options → Choose what closing the lid does
When I close the lid: Do nothing
This is also where you tell staff: "This laptop is the gym's server. Do not close the lid. Do not shut it down." The mystique helps.
Part 3: The Gate Control Problem — Why DENIED Wasn't Enough
The original system logged DENIED in the database when an expired member scanned their finger. But the door still opened. The DENIED label was purely informational — the device itself didn't know anything had changed.
To physically prevent the door from opening for expired members, the change has to happen on the device itself, not in the database.
The ZK device controls the door relay. When a fingerprint is matched, the relay fires and the door opens. If no fingerprint template exists for a user, the device shows a red failure screen and the relay doesn't fire. No template = no access. No middleware required.
This is the cleanest possible implementation: delete the finger template from the device when a subscription expires, and restore it when they renew.
The Database Change
First, add two columns to trn_member_biometric_mappings to store the template data before deletion:
ALTER TABLE trn_member_biometric_mappings
ADD COLUMN mbm_finger_template LONGTEXT,
ADD COLUMN mbm_uid INT DEFAULT NULL;
mbm_uid stores the ZK internal UID (different from device_user_id). mbm_finger_template stores the raw finger template bytes as a JSON array so we can restore them later.
The Sync Script
# sync_access.py
from zk import ZK
from zk.user import User
from zk.finger import Finger
import requests
from config import *
RAILS_API_BASE = "https://spine-fitness.com"
DEVICE_SN = "NFZ8253402448"
def get_access_status():
"""
Fetch all mapped members and their subscription status from Rails.
Returns list of {device_user_id, access, name, user_info} dicts.
"""
try:
# Wake Render from idle before the real request
requests.get(RAILS_API_BASE, timeout=30)
except:
pass
try:
response = requests.get(
f"{RAILS_API_BASE}/api/access_status",
params={"compcode": COMP_CODE, "device_sn": DEVICE_SN},
timeout=60
)
return response.json().get("users", [])
except Exception as e:
print(f"Could not fetch access status: {e}")
return []
def save_template_to_rails(device_user_id, uid, templates):
"""
Before deleting a user from the device, save their finger templates
to the Rails DB so we can restore them on renewal.
"""
try:
template_data = [
{
"uid": t.uid,
"fid": t.fid,
"valid": t.valid,
"template": list(t.template) # bytes → list for JSON serialization
}
for t in templates
]
requests.post(
f"{RAILS_API_BASE}/api/biometric_mappings/save_template",
json={
"compcode": COMP_CODE,
"device_user_id": device_user_id,
"device_sn": DEVICE_SN,
"uid": uid,
"templates": template_data
},
timeout=60
)
except Exception as e:
print(f" Could not save template to Rails: {e}")
def restore_user_to_device(conn, user_info):
"""
Re-add a previously blocked user to the device using their saved templates.
Called when their subscription is renewed.
"""
uid = user_info.get("uid")
device_user_id = user_info.get("device_user_id")
name = user_info.get("name", "Member")
templates_data = user_info.get("templates")
if not uid or not templates_data:
print(f" No stored template for {name} — needs manual re-enrollment")
return
try:
user_obj = User(
uid=int(uid),
name=name[:24],
privilege=0,
password='',
group_id='',
user_id=str(device_user_id),
card=0
)
fingers = [
Finger(
uid=int(uid),
fid=t["fid"],
valid=t["valid"],
template=bytes(t["template"])
)
for t in templates_data
]
conn.save_user_template(user=user_obj, fingers=fingers)
print(f" Restored: {name} (device_user_id={device_user_id})")
except Exception as e:
print(f" Could not restore {name}: {e}")
def sync_device_access():
print("Starting access sync...")
users_status = get_access_status()
if not users_status:
print("No data from Rails — skipping sync")
return
# Build lookup keyed by device_user_id
access_map = {str(u["device_user_id"]): u for u in users_status}
zk = ZK(DEVICE_IP, port=DEVICE_PORT, timeout=DEVICE_TIMEOUT,
password=0, force_udp=False, ommit_ping=False)
try:
conn = zk.connect()
conn.disable_device()
# Snapshot of current device state
device_users = {str(u.user_id): u for u in conn.get_users()}
templates = conn.get_templates()
templates_by_uid = {}
for t in templates:
templates_by_uid.setdefault(t.uid, []).append(t)
for device_user_id, status in access_map.items():
access = status["access"] # "ALLOW" or "DENY"
name = status.get("name", "Unknown")
user_info = status.get("user_info", {})
if device_user_id in device_users:
user = device_users[device_user_id]
# Never touch admin/staff accounts (privilege=14)
if user.privilege == 14:
continue
if access == "DENY":
# Save templates to DB before deletion
user_templates = templates_by_uid.get(user.uid, [])
if user_templates:
save_template_to_rails(device_user_id, user.uid, user_templates)
conn.delete_user(uid=user.uid)
print(f" BLOCKED: {name} (device_user_id={device_user_id})")
# access == "ALLOW" and user exists on device → nothing to do
else:
pass
else:
# User not currently on device
if access == "ALLOW" and user_info.get("uid") and user_info.get("templates"):
# Previously blocked, subscription renewed → restore
restore_user_to_device(conn, user_info)
conn.enable_device()
print("Access sync complete.")
except Exception as e:
print(f"Sync error: {e}")
import traceback
traceback.print_exc()
finally:
try:
conn.enable_device()
conn.disconnect()
except:
pass
if __name__ == "__main__":
sync_device_access()
The Rails API Endpoint
The sync script needs to know who is active and who isn't:
# app/controllers/api/access_status_controller.rb
class Api::AccessStatusController < ApplicationController
skip_before_action :verify_authenticity_token
def index
compcode = params[:compcode]
device_sn = params[:device_sn]
today = Date.today
mappings = TrnMemberBiometricMapping.where(
mbm_compcode: compcode,
mbm_device_sn: device_sn,
mbm_is_active: 'Y'
)
users = mappings.map do |m|
has_active = TrnMemberSubscription
.where(ms_compcode: compcode, ms_member_id: m.mbm_member_id)
.where('ms_end_date >= ?', today)
.exists?
member = MstMembersList.find_by(id: m.mbm_member_id)
{
device_user_id: m.mbm_device_user_id,
access: has_active ? "ALLOW" : "DENY",
name: member&.mmbr_name || "Unknown",
user_info: {
uid: m.mbm_uid,
device_user_id: m.mbm_device_user_id,
name: member&.mmbr_name || "Unknown",
templates: m.mbm_finger_template ? JSON.parse(m.mbm_finger_template) : nil
}
}
end
render json: { status: true, users: users }
end
end
And the template save endpoint:
# app/controllers/api/biometric_mappings_controller.rb
def save_template
mapping = TrnMemberBiometricMapping.find_by(
mbm_compcode: params[:compcode],
mbm_device_user_id: params[:device_user_id],
mbm_device_sn: params[:device_sn]
)
return render json: { status: false, message: "Mapping not found" } unless mapping
mapping.update!(
mbm_uid: params[:uid],
mbm_finger_template: params[:templates].to_json
)
render json: { status: true }
end
Running Sync Automatically
The sync runs once on bridge startup and once every midnight — embedded as a background thread inside bridge.py:
# bridge.py (updated)
import threading
from sync_access import sync_device_access
SYNC_HOUR = 0 # midnight
def sync_scheduler():
print("Running initial access sync...")
sync_device_access()
while True:
now = datetime.now()
if now.hour == SYNC_HOUR and now.minute == 0:
print("Midnight sync starting...")
sync_device_access()
time.sleep(61) # prevent double-trigger within same minute
time.sleep(30)
def main():
sync_thread = threading.Thread(target=sync_scheduler, daemon=True)
sync_thread.start()
# ... rest of bridge.py unchanged
What the Member Experiences
Member with expired subscription scans finger:
→ Device shows red ✗ screen
→ "Unregistered ID" or "Access Denied"
→ Door relay does NOT fire
→ Gate stays closed
Member renews subscription in Rails:
→ Next midnight sync runs
→ Rails returns "ALLOW" for this member
→ Sync finds stored templates in DB
→ Templates re-uploaded to device
→ Next scan: green ✓, door opens
The gate control is entirely device-side. Even if the laptop is off, the internet is down, or the Rails app is unreachable — if a template was deleted, the door stays closed. The device enforces access independently.
Part 4: Fingerprint Enrollment from the Web UI
The original system required three separate manual steps to enroll a new member's fingerprint:
- Add the member in the web app
- Go to the biometric device, navigate the menu, register their finger manually
- Note the device-assigned user ID, come back to the computer, manually insert a row into
trn_member_biometric_mappings
Step 3 was where it always broke. Staff would forget the device ID. They'd write it on a scrap of paper and lose it. The mapping table would get out of sync.
The fix: a local Flask API running on the gym laptop alongside bridge.py. The web app's member profile page sends an HTTP request to localhost:5000/enroll, which creates the device user and triggers the enrollment screen — all from a single button click.
Why localhost?
The enrollment API only needs to be reachable from the gym laptop, because enrollment always happens in person at the gym. The browser making the request and the Flask server are on the same machine. No ngrok, no tunnels, no external exposure.
# enroll_api.py
from flask import Flask, request, jsonify
from flask_cors import CORS
from zk import ZK
from zk.user import User
import requests as req
from config import *
app = Flask(__name__)
CORS(app)
RAILS_API_BASE = "https://spine-fitness.com"
DEVICE_SN = "NFZ8253402448"
def get_next_uid_and_user_id(conn):
users = conn.get_users()
if not users:
return 1, '1'
max_uid = max(u.uid for u in users)
existing_ids = [int(u.user_id) for u in users if str(u.user_id).isdigit()]
max_user_id = max(existing_ids) if existing_ids else 0
return max_uid + 1, str(max_user_id + 1)
@app.route('/health', methods=['GET'])
def health():
return jsonify({'status': True, 'message': 'Enrollment API running'})
@app.route('/enroll', methods=['POST'])
def enroll():
data = request.json
member_id = data.get('member_id')
member_name = data.get('member_name', 'Member')
compcode = data.get('compcode', 'SF')
zk = ZK(DEVICE_IP, port=DEVICE_PORT, timeout=DEVICE_TIMEOUT,
password=0, force_udp=False, ommit_ping=False)
try:
conn = zk.connect()
conn.disable_device()
new_uid, new_device_user_id = get_next_uid_and_user_id(conn)
# Create user record on device (no finger template yet)
user_obj = User(
uid=new_uid,
name=member_name[:24],
privilege=0,
password='',
group_id='',
user_id=new_device_user_id,
card=0
)
conn.save_user_template(user=user_obj, fingers=[])
# Trigger fingerprint enrollment screen on device
conn.enable_device()
conn.enroll_user(uid=new_uid, temp_id=0)
# Save mapping to Rails
req.post(
f"{RAILS_API_BASE}/api/biometric_mappings",
json={
'compcode': compcode,
'member_id': str(member_id),
'device_user_id': new_device_user_id,
'device_sn': DEVICE_SN,
'uid': new_uid
},
timeout=30
)
return jsonify({
'status': True,
'message': f'Device ready — ask {member_name} to scan finger now!',
'device_user_id': new_device_user_id
})
except Exception as e:
return jsonify({'status': False, 'message': str(e)}), 500
finally:
try:
conn.enable_device()
conn.disconnect()
except:
pass
if __name__ == '__main__':
app.run(host='127.0.0.1', port=5000, debug=False)
The UI: Fingerprint Section on Member Profile
The member edit page now has a biometric section that shows the current enrollment status and relevant actions:
<%# In member_list/add_member.html.erb %>
<% if @member && @member.id %>
<% mapping = TrnMemberBiometricMapping.find_by(
mbm_compcode: session[:loggedUserCompCode],
mbm_member_id: @member.id.to_s,
mbm_is_active: 'Y'
) %>
<div class="form-group row" style="margin-top: 20px;">
<div class="col-md-12">
<div class="card" style="padding: 15px;">
<h5>Biometric Fingerprint</h5>
<% if mapping %>
<p>
<span class="badge badge-success">✓ Enrolled</span>
Device User ID: <strong><%= mapping.mbm_device_user_id %></strong>
</p>
<button type="button"
onclick="enrollFinger(<%= @member.id %>, '<%= @member.mmbr_name %>')"
class="btn btn-warning btn-sm">
Re-enroll Finger
</button>
<button type="button"
onclick="removeMapping(<%= mapping.id %>)"
class="btn btn-danger btn-sm">
Remove Mapping
</button>
<% else %>
<p class="text-muted">No fingerprint enrolled.</p>
<button type="button"
onclick="enrollFinger(<%= @member.id %>, '<%= @member.mmbr_name %>')"
class="btn btn-primary btn-sm">
👆 Enroll Fingerprint
</button>
<%# Manual mapping for members already on device %>
<div style="margin-top: 10px;">
<input type="text" id="manual_device_uid"
class="form-control form-control-sm"
placeholder="Device User ID (manual)" style="width: 200px; display: inline;"/>
<button type="button"
onclick="saveManualMapping(<%= @member.id %>, '<%= @member.mmbr_name %>')"
class="btn btn-secondary btn-sm">
Save Manual Mapping
</button>
</div>
<% end %>
<div id="enroll-status" class="mt-2"></div>
</div>
</div>
</div>
<% end %>
function enrollFinger(memberId, memberName) {
$("#enroll-status").html('<span class="text-info">⏳ Checking enrollment service...</span>');
// First ping localhost to confirm we're on the gym laptop
$.ajax({
url: 'http://localhost:5000/health',
type: 'GET',
timeout: 3000,
success: function() {
doEnroll(memberId, memberName);
},
error: function() {
$("#enroll-status").html(
'<span class="text-warning">⚠ Enrollment only works from the gym laptop.</span>'
);
}
});
}
function doEnroll(memberId, memberName) {
$.ajax({
url: 'http://localhost:5000/enroll',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ member_id: memberId, member_name: memberName, compcode: 'SF' }),
timeout: 30000,
success: function(resp) {
if (resp.status) {
$("#enroll-status").html('<span class="text-success">✓ ' + resp.message + '</span>');
setTimeout(function(){ location.reload(); }, 4000);
} else {
$("#enroll-status").html('<span class="text-danger">Error: ' + resp.message + '</span>');
}
}
});
}
The health check before enrollment is important — it gives a clear message when someone tries to enroll from home rather than a confusing network error.
Part 5: The Android Backup Bridge
The gym's spare Android phone was already on 24/7 (used for YouTube/music). I put it to work as a backup bridge using Termux.
# Termux setup
pkg update && pkg upgrade -y
pkg install python -y
pip install pyzk requests # note: pyzk, NOT zk
# Create startup script (runs on phone boot via Termux:Boot)
mkdir -p ~/.termux/boot
cat > ~/.termux/boot/start-bridge.sh << 'EOF'
#!/data/data/com.termux/files/usr/bin/sh
termux-wake-lock
cd /data/data/com.termux/files/home
python3 bridge.py &
EOF
chmod +x ~/.termux/boot/start-bridge.sh
The key insight: pip install zk installs Zipkin (a distributed tracing library). The correct package is pip install pyzk. Easy mistake to make, hard to debug if you don't know to look for it.
The termux-wake-lock command prevents Android from killing Termux when it's backgrounded — critical for a bridge that needs to run continuously while YouTube plays in the foreground.
Set battery optimization to "Unrestricted" for Termux in Android Settings to prevent aggressive power management from terminating the process.
Dual Bridge Architecture
With both bridges running:
Phone on + Laptop off → attendance works via phone ✓
Phone off + Laptop on → attendance works via laptop ✓
Both on → both send, Rails deduplication ignores duplicates ✓
The Rails duplicate check (same member + same minute) means double-sends are silently ignored. Running two bridges simultaneously is safe.
Part 6: The ADMS Investigation (And Why I Abandoned It)
I spent significant time trying to make ADMS (the device's built-in cloud push protocol) work. ADMS would have eliminated the need for the Python bridge entirely — the device would push attendance directly to Rails.
The investigation revealed the exact failure chain:
-
DNS resolution worked — after changing the device's DNS from the router (
192.168.31.1) to Google's (8.8.8.8), the device could resolvespine-fitness.com -
Port 80 was reachable —
curl http://spine-fitness.com/iclock/getrequestfrom the same network returnedOK - Cloudflare activated successfully — Flexible SSL mode set, domain proxied through Cloudflare
-
Rails ADMS controller was deployed — routes for
/iclock/cdataand/iclock/getrequestwere live and returningOK -
The device still never connected — zero
/iclock/requests appeared in Render logs even after DNS fix and reboot
The most likely root cause: the router has per-device firewall rules blocking outbound HTTP from IoT/embedded devices (MAC address filtering or device category classification). The laptop and phone on the same WiFi could reach the internet fine; the ZK device could not — despite identical network configuration.
The ADMS controller code remains in the codebase for future use if the router configuration ever changes:
# app/controllers/api/adms_controller.rb
class Api::AdmsController < ApplicationController
skip_before_action :verify_authenticity_token
def getrequest
render plain: "OK"
end
def handshake
render plain: "OK"
end
def receive
body = request.body.read
Rails.logger.info "ADMS received: #{body}"
sn_match = body.match(/SN=([^\s&\r\n]+)/i)
device_sn = sn_match ? sn_match[1] : 'NFZ8253402448'
body.each_line do |line|
line = line.strip
next if line.empty?
next if line.start_with?("ATTLOG")
next unless line.split("\t")[0].to_s.match?(/\A\d+\z/)
parts = line.split("\t")
device_user_id = parts[0].to_s.strip
timestamp = parts[1].to_s.strip
punch_time = Time.zone.parse(timestamp) rescue nil
next unless punch_time
next if punch_time.to_date < Date.today
process_attendance(device_user_id, punch_time, device_sn)
end
render plain: "OK"
end
private
def process_attendance(device_user_id, punch_time, device_sn)
# same logic as BiometricAttendancesController
end
end
If you're starting fresh and your router doesn't filter IoT devices, ADMS is the cleaner solution — no bridge needed at all.
The Updated Architecture
┌─────────────────────────────┐
│ ZK Fingerprint Device │
│ 192.168.31.151:4370 │
│ Door relay connected │
└──────────┬──────────────────┘
│ pyzk TCP
┌────────────────┼────────────────┐
│ │ │
▼ ▼ │
┌────────────────┐ ┌──────────────────┐ │
│ Python Bridge │ │ Enroll API │ │
│ bridge.py │ │ enroll_api.py │ │
│ (gym laptop) │ │ Flask :5000 │ │
│ │ │ (gym laptop) │ │
│ + sync_access │ └────────┬─────────┘ │
│ thread │ │ localhost POST │
└───────┬────────┘ │ │
│ │ │
│ HTTPS POST │ │
▼ ▼ │
┌───────────────────────────────────────────┐ │
│ Cloudflare (Flexible SSL) │ │
└───────────────────┬───────────────────────┘ │
│ HTTPS │
▼ │
┌───────────────────────────────────────────┐ │
│ Ruby on Rails (Render) │ │
│ │ │
│ POST /api/biometric_attendances │ │
│ POST /api/biometric_mappings │ │
│ POST /api/biometric_mappings/save_template│ │
│ GET /api/access_status │ │
└───────────────────┬───────────────────────┘ │
│ │
▼ │
┌───────────────────────────────────────────┐ │
│ MySQL (CleverCloud) │ │
│ │ │
│ trn_member_attendances │ │
│ trn_member_biometric_mappings │ │
│ + mbm_finger_template (LONGTEXT) │ │
│ + mbm_uid (INT) │ │
│ trn_member_subscriptions │ │
└───────────────────────────────────────────┘ │
│
┌───────────────────────────────────────────┐ │
│ Android Phone (Termux backup bridge) │──┘
│ Same WiFi, same device, different host │
└───────────────────────────────────────────┘
Key Engineering Decisions in Retrospect
1. Delete the template, not just a flag.
The first instinct is to add an is_blocked flag somewhere and check it in a middleware layer. But that still involves a network call at scan time — and the network can be down. Deleting the template makes the device itself the enforcement layer. No network, no middleware, no latency. The device just can't match a finger that isn't there.
2. Store templates before deletion.
This is the part that's easy to skip and catastrophic if you do. Always save the template bytes to the database before calling conn.delete_user(). The sync script saves first, deletes second — if the save fails, the delete doesn't happen.
3. Never touch admin accounts (privilege=14).
The sync script explicitly skips any user with privilege == 14. These are device admins — staff, trainers, the owner. They should always have access regardless of subscription status. One unguarded delete_user on an admin account during a sync run would be a very bad day.
4. The enrollment API health check.
Before sending the enroll command, the browser pings localhost:5000/health. If it doesn't respond, the user sees "Enrollment only works from the gym laptop" instead of a generic network error. Small UX detail, but it prevents a lot of confused support calls.
5. Render cold starts are real.
The sync script pings the Rails root URL before the actual access_status request. Render's free tier sleeps after inactivity. Without the warmup ping, the first real request would time out, the sync would abort, and nobody would get blocked or restored. The warmup adds ~2 seconds but makes the sync reliable.
Current State
The system has been running in production for several months. The gym has:
- Zero attendance records missed due to bridge downtime (dual bridge coverage)
- Members with expired subscriptions physically blocked at the door
- New member enrollment taking under 30 seconds from web form to working fingerprint
- The gym owner checking the live attendance dashboard from home on their phone
The notebook is in a drawer somewhere. Nobody's opened it.
🔗 Live: spine-fitness.com
💻 Source: github.com/imlakshay08/spine-fitness-gym-management-system
If you've dealt with biometric hardware integration or the ZK device ecosystem — I'd love to compare notes in the comments.