Home›Gymora›Manual
Gymora · Manual

Gymora Manual

Gymora — User Manual

A gym membership register and front desk, on your own server. Version 4.1.3 [src: app/controllers/api.php:16].

About this manual

Every statement here was written by reading Gymora's own source, and each one carries the file it came from in a bracketed src marker. Paths are relative to the folder this docs directory sits in. If a sentence disagrees with the application, the application is right and this manual has a bug — the markers exist so you can check rather than take our word for it.

Nothing is described that the code does not do. Where something is deliberately limited, that is said plainly rather than left out.

The question the product exists to answer

A gym's front desk lives on one question asked hundreds of times a day: can this person walk in right now? The answer depends on membership status, an end date that may have quietly passed, and — for class packs — how many credits are left. Getting it wrong means either turning away a paying member or letting an expired one train for free [src: app/src/Membership.php:5].

All of that logic sits in pure functions with no database and no network, which is what makes it testable in isolation [src: app/src/Membership.php:11].

Money is integer cents, dates are YYYY-MM-DD, and "today" is computed in the business's configured timezone [src: app/src/Membership.php:16].

The check-in guard

The guard reads only three things — status, end date and credits remaining — and returns whether the person may come in, why not if they may not, and whether a credit should be burned [src: app/src/Membership.php:170].

The refusals are specific rather than generic [src: app/src/Membership.php:178]:

SituationWhat the desk is told
No membership"No active membership on file. Assign a plan before checking in."
Frozen"Membership is frozen. Unfreeze it before checking in."
Canceled"Membership has been canceled."
Expired"Membership expired on date. Renew to continue." — naming the date where one is known
Class pack, no credits"No class credits remaining. Renew the pass to continue."

A person on the desk can read any of those aloud to the member without translating it.

Status is computed, not just stored

A membership's status "as of today" is the stored status, except that an active membership whose end date has passed is really expired [src: app/src/Membership.php:157].

Frozen and canceled are manual holds and are never flipped automatically [src: app/src/Membership.php:153]. No membership at all reports as none.

So a membership cannot sit in the database claiming to be active a month after it ran out.

Legal transitions

The transition table is explicit [src: app/src/Membership.php:219]:

FromMay move to
activefrozen, expired, canceled
frozenactive, expired, canceled
expiredactive (by renewal), canceled
cancellednothing — terminal

A move to the status it already holds is refused [src: app/src/Membership.php:233].

Plans and terms

Four billing periods [src: app/src/Membership.php:27], and each produces a different term [src: app/src/Membership.php:244]:

  • Monthly — one month from the start date.
  • Annual — twelve months from the start date.
  • Class pack — no end date at all; it carries credits instead.
  • Day pass — starts and ends on the same day.

Credits

A check-in on a credit-based membership burns one [src: app/src/Membership.php:206], and the count never drops below zero [src: app/src/Membership.php:213]. A membership with no credit count is unlimited, and stays unlimited [src: app/src/Membership.php:213].

"Expiring soon"

Only an active, dated membership whose end date falls between today and today plus your window counts as expiring [src: app/src/Membership.php:275].

Already-expired, frozen, canceled and open-ended memberships never appear in that list — a class pack has no end date to be near [src: app/src/Membership.php:277].

Users and roles

Three roles, chosen against how a gym actually staffs a front desk [src: app/controllers/app.php:14]:

  • viewer — reads the register and reports; a bookkeeper or an owner's accountant [src: app/controllers/app.php:15].
  • member — everything a front-desk shift needs: check people in, take a payment, book a class. Cannot change plans or prices, cannot touch settings, cannot delete [src: app/controllers/app.php:16].
  • admin — the owner: plans, pricing, settings, roles, integrations and destructive actions [src: app/controllers/app.php:18].

Existing users migrate to admin on upgrade, because an upgrade never removes access [src: app/controllers/app.php:19].

This table was produced by executing the product's own permission function against every permission the routes ask for.

Permissionviewermemberadmin
member.viewyesyesyes
class.viewyesyesyes
report.viewyesyesyes
audit.viewyesyesyes
checkin.create—yesyes
payment.record—yesyes
member.edit—yesyes
class.book—yesyes
class.edit——yes
member.delete——yes
settings.edit——yes
team.manage——yes

⚠️ The audit trail is readable by a viewer

Worth stating plainly, because it is easy to assume otherwise.

Gymora's role map grants the viewer role *.view — a wildcard rather than a list [src: app/controllers/app.php:24]. Because the audit permission is itself named audit.view, the wildcard matches it, so a viewer can open the audit trail and download it as CSV [src: app/controllers/v3.php:16], [src: app/controllers/v3.php:26].

The audit trail records who did what and when, filterable by actor, action, entity and date.

The source's own comment lists audit.view among the permissions the admin role "also covers … via the '*' wildcard" [src: app/controllers/app.php:26], which reads as though it were admin-only. The behavior and that comment do not agree, and the behavior is what this manual describes.

If your viewer seat is an outside bookkeeper, know that it currently includes the staff activity record. This has been raised against the product.

Backups follow the model

Gymora's database backup downloads are permission-gated, requiring the settings permission [src: app/controllers/api.php:257]. A viewer cannot download the database.

The API and agent access

The agent endpoint uses the same key as the REST API, so revoking a key revokes the agent [src: app/controllers/mcp.php:37]. A key carries a scope, and read and write are enforced per tool rather than per method [src: app/controllers/mcp.php:38].

Five tools, of which one writes [src: app/controllers/mcp.php:51]: listing members with their check-in eligibility, member detail, expiring memberships and a revenue report on the read side; check_in on the write side.

The instructions tell an agent the guard up front — that the server refuses a check-in with no credits left, or a frozen, canceled or expired membership, or none at all — and that check_in writes and may spend a credit [src: app/controllers/mcp.php:46].

Members online (3.2)

The member page. On a member's record, Member page link creates their private page at /m/<link> (shown once; only its SHA-256 hash is stored, and it is left out of the shareable JSON backup). Email them a link sends it, when SMTP is set up. New link replaces it and Turn off kills it [src: app/src/Portal.php, app/controllers/v32.php]. The page shows that member's own membership, credits and renewal date, anything due with your payment link, and the next two weeks of classes. Nothing about any other member is on it. A member can book only when the check-in guard would let them in [src: app/src/Portal.php]; they can cancel up to class_cancel_hours before the class (2 by default, Settings → Members online). Wrong links are rate-limited like a password [src: app/controllers/v32.php].

Class waitlist. When a class is full, a member (from their page) or the desk (Classes → Waitlist) can queue for it. Canceling a booking hands the seat to the first person in line, booked through the same engine as every other booking, and emails them when SMTP is set up [src: app/src/Classes.php]. The class list shows how many are waiting. Booking still spends no credit; check-in does.

Joining online. Tick Offer on the join page on the plans you want to sell, and turn on Joining online. /join takes name, email, phone and an emergency contact; it creates the member and their first charge, and emails how to pay and their member page link. The membership starts when you mark that first charge paid, or when the new member pays it by card on their member page (with card payments on, below), so nobody walks in on an unpaid sign-up; a free plan starts at once. An address that is already a member gets its member page link instead of a second account, and the page says the same thing either way [src: app/src/Join.php]. Joining stays closed until SMTP is set up.

Billing without a card (3.2)

Settings → Billing: paste your own payment link (https), then choose [src: app/src/Billing.php, app/cron/billing.php]:

  • Billing run. Run php cron/billing.php once a day. On an auto-renewing monthly or annual membership's renewal date (its end date), the membership is extended by one term and a charge for the plan price is written, due that day. A run missed for up to 31 days still renews from the old end date, without a gap. The renewal claims the membership by moving its end date, so two runs cannot charge the same term twice. Class packs and day passes are never renewed by the run.
  • Email the member each new charge, with the amount, the due date and your payment link (off by default; needs SMTP).
  • Trials that roll over. A plan with the period Trial, then another plan lasts the days you set (1–90, 7 by default) and names the monthly or annual plan it continues on [src: app/src/Billing.php rollTrials]. When an auto-renewing trial ends, the billing run starts the member on that plan the same day (no gap) and writes its first charge, due that day, emailed like any other charge if you have that on. It happens once per trial. A trial with auto-renew off just ends; offered on the join page, a trial with a next plan is taken with auto-renew on, and the page says what it continues as and at what price.
  • One reminder after N days of an unpaid due date (7 by default, 0 = never), once per charge. Send reminder on the arrears list and on a member's record sends one by hand; a reminder that fails to send is not counted.

Reconciling stays yours: mark the charge paid when the money arrives, because a pasted payment link cannot tell Gymora it was paid. Gymora never takes a card and never adds a percentage. With card payments switched on (next section), a charge the member pays by card on their member page marks itself paid.

Card payments through your own Stripe account

Off until you switch it on (Settings → Card payments). When it is on, a member pays by card from their own member page and the money goes to your own Stripe account. The card is typed into Stripe's checkout page, never into Gymora, and Gymora takes no cut [src: app/src/CardPay.php].

What a member can pay.

  • A charge. Each unpaid charge on the member page has a Pay by card button: a join charge, a renewal charge from the billing run, or a charge you added.
  • A plan. Buy online lists the plans you ticked Offer on the join page that have a price. A class pack adds its credits to the member's pack on the same plan, or starts a new pack. A monthly or annual plan the member already has gets one more term, counted from today or from its end date, whichever is later, so no days are lost; otherwise a new membership starts today, set to auto-renew. A day pass starts and ends today [src: app/src/CardPay.php startBoughtPlan].

When a payment counts. Coming back from Stripe's page changes nothing: the member page says the payment is being confirmed. Only Stripe's signed webhook records it. Then the charge is marked paid (a join charge starts its membership, as when you mark it paid by hand), or the plan starts. The payment shows as "Card (Stripe)" with the date, and sends the same payment.recorded webhook and owner email as a payment you mark paid [src: app/controllers/cardpay.php]. Each Stripe event is recorded once, and each checkout pays once, even when Stripe sends an event twice or sends two events for one payment [src: app/src/CardPay.php handle, fulfil].

Setting it up.

  1. In Stripe, copy a secret key (sk_live_… or sk_test_…). A restricted rk_… key works if it has write access to Checkout Sessions.
  2. In Stripe, under Developers → Webhooks, add an endpoint at https://<your Gymora address>/hooks/stripe with these events: checkout.session.completed, checkout.session.async_payment_succeeded, checkout.session.async_payment_failed and checkout.session.expired. Copy the endpoint's signing secret (whsec_…).
  3. In Gymora, Settings → Card payments: tick Take card payments on member pages, paste both keys, and save. The status line says what is still missing.

The keys are write-only (the form never shows them again) and are left out of the shareable JSON backup. Test-mode keys work: the status line says "Stripe test mode", and Stripe's test cards go through without real money. The charge is in your currency code (Settings → Gym), which needs cents: USD, EUR, GBP, CAD, AUD and the other two-decimal currencies listed in CardPay::CURRENCIES. Card payments cannot be set up on the public demo.

Monthly memberships. Gymora does not use Stripe subscriptions, and nothing is taken from a card automatically. The billing run already extends each auto-renewing membership and writes one charge per term. With card payments on, the charge email tells the member to pay by card on their member page, and each term is one payment, made when the member presses Pay by card [src: app/src/Billing.php text].

What it refuses. A webhook with a wrong signature, or one more than five minutes old, is refused and writes nothing. The signature is checked over the request body exactly as it arrived. A paid event whose amount or currency is not the checkout's records nothing; it is listed under Last webhook deliveries in Settings for you to check in Stripe. A checkout page expires after an hour. A payment Stripe has not confirmed yet (some bank methods are delayed) is recorded when Stripe's async_payment_succeeded event arrives.

Paid twice. If you mark a charge paid at the desk while the member is paying it on Stripe's page, the card payment is not recorded a second time. The checkout is marked "flagged", the audit trail notes it, you get an email (to "Send notifications to") asking you to refund that card payment in Stripe, and the member gets an email saying the gym will refund it [src: app/src/CardPay.php flagLatePayment]. The two emails need your SMTP.

Refunds. Gymora has no refund button and does not hear about refunds. Make the refund in your Stripe dashboard. Then record it in Gymora by hand: add a line to the member's Notes (their record → Edit) saying what was refunded and when, and if the membership should stop, freeze or cancel it on their record. The original payment stays in Gymora's payments list and revenue report; Stripe's own reports show the refund.

Messages to members (3.2)

Settings → Messages to members has three messages, each off until you tick it and each with wording you can edit (placeholders {name}, {gym}, {class}, {when}, {days}) [src: app/src/Messages.php]:

  • Class reminder, a set number of hours before a class the member is booked into;
  • "We have not seen you", once a member with an active membership has gone the days you choose without a check-in (once per quiet spell: their next check-in resets it);
  • Welcome, when you add a member with an email address (with the waiver signing link, when Waiverly is connected).

cron/messages.php, run hourly, sends the first two. Email goes through your own SMTP. Members marked Texts OK on their record also get a text through your own SMS gateway (Settings → Texts; any HTTPS gateway, the key write-only, a test button) [src: app/src/Sms.php]. A member ticked No automated messages, or who pressed Stop reminder emails and texts on their member page, gets none. Each message is claimed before it is sent, so an overlapping cron cannot send it twice, and a send that fails releases its claim.

The waiver at the door (3.2)

Settings → Waivers connects your own Waiverly: its address, an API key (read-only is enough; write-only here and left out of the backup), the waiver's public signing link and, optionally, which template counts [src: app/src/WaiverLink.php]. A member's record then shows Waiver valid until …, expired, needs re-signing or no waiver on file, read from Waiverly by the member's email address (GET /api/waivers/check) and remembered for 12 hours, with Check again and Email the signing link (their name and email filled in). The front desk search shows a missing or expired waiver as a warning, a desk check-in notes it, and the kiosk tells the member to see the desk. None of these refuses the check-in: staff decide. Only the member's email address is sent to Waiverly.

What Gymora does not do

It does not take payments itself. It records that one was taken; payment.record is a front-desk permission. It can write renewal charges and email your own payment link (3.2), and with card payments on, members pay by card on Stripe's own page through your own Stripe account. The card never passes through Gymora, and it does not refund: refunds are made in Stripe.

It does not decide your prices or plans. Those are the owner's, behind the settings permission [src: app/controllers/app.php:18].

It does not auto-unfreeze or auto-cancel. Frozen and canceled are manual holds [src: app/src/Membership.php:153]; only the passage of an end date is applied automatically.

It does not let a canceled membership come back [src: app/src/Membership.php:219]. Canceled is terminal; a returning member gets a new membership.

Looking after your install (from 4.0)

Updates from inside Gymora

Updates in the menu opens the Updates page (/updates) [src: app/views/updates.php]. Paste your license key once and press Save key: either key works, the one in your Lemon Squeezy receipt or the one on your order page (it starts with OWX-). Check for updates asks the store for the newest release your license covers; Gymora never checks by itself.

When a new version is ready, Back up and update to that version backs up your data and the current version, installs the new one and checks it. If the update is interrupted, or the new version's database step or that check fails, the previous version is put back by itself. Gymora is unavailable for about a minute while this runs. The update replaces Gymora's files, including any you edited, except .htaccess, .user.ini and robots.txt: an edited copy of those is kept, with the new version's copy beside it as .new to compare and merge. The Health page lists any file that differs from the version installed, so you can see that before you update. A copy of the previous version (data/updates/code-<version>/) and a backup of your data from just before the update stay in data/updates/ [src: app/src/Updater.php]. If Gymora shows an error page after an update anyway, that is your way back: copy the files from code-<version>/ over the app's files, then restore the backup on the backup page. Updating from the page needs PHP's sodium and Phar extensions; if one is missing, the Updates and Health pages say so and updates stay by hand. Where Gymora is hosted for you, the host updates it and the page says so.

Reaching 4.0 from a 3.x version is the last update done by hand: replace the files as before (config.php and data/ stay, and the database migrates itself on the next page load). From 4.0 on, the Updates page does it.

Starting with sample data

The installer offers Start with sample data: a gym's members, plans, payments and check-ins to try things on. You can remove it in one click. [src: app/install/index.php:126]. It is ticked by default; untick it to start empty. The sample is added when you open the first page after the installer.

When you are done with it, press the remove button in the sample banner. It asks "Remove the sample members, plans, payments and check-ins? Your own records stay." Only the sample goes: your own records stay, and a sample record that one of your own records uses is kept and named in the report [src: app/src/SampleData.php].

Backups by themselves, and a copy somewhere else

On the Restore page (/restore), Back up once a day by itself is on by default; untick it to stop [src: app/views/restore.php:71]. The first visit at least 24 hours after the last backup starts the next one, after the page has loaded, so no cron job is needed. The newest 14 stay in data/backups/, with credentials left out of them (a restore keeps the live values for those). Back up now takes one straight away.

To keep a copy somewhere else as well, fill in an S3-compatible bucket (Backblaze B2, Wasabi, AWS S3 or your own MinIO): the bucket address (an https endpoint), the region, the bucket name, an optional folder, and the access key ID and secret key. Leave the address empty to keep backups on this server only. The copy goes only to the bucket you set; nothing is sent to Ownware. Once a week Gymora reads the newest backup back through the restore's own dry run and keeps the result for the Health page [src: app/src/BackupTargets.php].

On Nginx: keep data/ private

On Apache, the .htaccess that ships with Gymora keeps data/ (your backups included), src/, config.php and the other private files off the web. Nginx does not read .htaccess, so add these three blocks to your server block, above the block that passes .php files to PHP (Nginx uses the first one that matches):

location ~ ^/(src|data|bin|tests|controllers|deploy|cron)/ { deny all; }
location ~ ^/views/.+\.php$ { deny all; }
location ~ (^|/)(config\.php|\.demo-mode)$|\.sqlite$ { deny all; }

Then open /config.php in a browser: it should answer 403. Only the template files under views/ are closed, not the folder: the app's own saved-view addresses start with /views/ too, and they must reach it.

A complete server block for a new site (PHP-FPM 8.3; change the name, the folder and the socket to yours):

server {
    listen 80;
    server_name example.com;
    root /var/www/gymora/app;
    index index.php;

    location ~ ^/(src|data|bin|tests|controllers|deploy|cron)/ { deny all; }
    location ~ ^/views/.+\.php$ { deny all; }
    location ~ (^|/)(config\.php|\.demo-mode)$|\.sqlite$ { deny all; }

    location = /install/ { rewrite ^ /install/index.php last; }
    location / { try_files $uri /index.php?$query_string; }

    location ~ \.php$ {
        try_files $uri =404;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}

The Health page

Health in the menu (/health) shows, one line each, whether the install is looking after itself: the PHP version and extensions, disk space, whether data/ is writable, email, the age of the last backup, the offsite copy, the weekly check, whether an update is waiting, and any file that differs from the version installed. Each line says what to do when it is not fine. The page only reads: it never changes anything and never contacts anything [src: app/src/Health.php].

Connect it to other tools (Zapier, Make, n8n)

Gymora sends a webhook the moment something happens and accepts calls to its API, so Zapier, Make and n8n can connect to it without a plugin. The steps, the events and example recipes are in API.md, in the section "Connect it to Zapier, Make or n8n"; the API page of Gymora on the store shows the same file [src: app/API.md].

New in 4.1.2: sign-in limits

New in 4.1.2 (security update): Two-step codes are now limited (10 wrong per person or per address in 10 minutes), and wrong passwords count per account as well as per address. An installed app whose database does not answer keeps its web installer closed instead of showing the install form. A recognized kiosk code no longer resets the kiosk's wrong-code count. On Ownware Cloud, opening the app from the console starts a fresh session.

Wrong passwords are counted per address and, since 4.1.2, per account too (the email typed, in any case, stored only as a hash). After 10 wrong passwords in 10 minutes the sign-in at /login answers "Too many attempts — wait a few minutes and try again." for that address, and for that account from any address. A right password clears both counts [src: app/src/LoginRate.php:40].

Wrong two-step codes (for people who turned two-step sign-in on under /security) are counted per person and per address. After 10 wrong codes in 10 minutes the code step answers "Too many wrong codes. Wait ten minutes, then sign in again. A recovery code works in place of the code." even for the right code, and the half-finished sign-in is dropped, so the password is asked again [src: app/src/LoginRate.php:91]. The right code clears that person's count; an address's count runs out ten minutes after its wrong codes. Before 4.1.2 the code step had no limit.

If you are locked out: wait ten minutes, then sign in again with your password and a current code, or one of your recovery codes (each works once). If you lost both the authenticator and the recovery codes, whoever runs the server can turn two-step sign-in off for you in Gymora's database (by default the SQLite file in data/, or your MySQL database): UPDATE users SET totp_secret = '' WHERE email = 'you@example.com'; Then sign in with your password and turn it on again under /security.

The kiosk (/kiosk) counts wrong member codes per address: after 10 in 10 minutes it answers "Too many attempts. Ask the front desk." Since 4.1.2 a recognized code no longer resets that count (before, one valid code let someone keep trying others); the count runs out ten minutes after the wrong codes [src: app/controllers/v3.php:671].

On Ownware Cloud, opening Gymora from the console starts a fresh session [src: app/index.php:61].

The web installer stays closed when the database does not answer: with config.php in place, /install now answers 503 "This app is already installed (config.php exists), but its database is not answering, so the installer stays closed." and writes nothing until the database answers again. Before 4.1.2 a database outage (or a missing SQLite file) showed the install form there, and whoever found it could set up a new administrator. To install again from scratch, remove config.php first [src: app/install/index.php:22].

New in 4.1.3: security update

New in 4.1.3 (security update): no other site can put Gymora's pages in a frame; the /mcp address refuses requests from other sites' pages; company sign-in is stricter; and the audit CSV treats one more first character as text. Nothing else changed: no feature, screen, setting or database table, and your data, config.php and data/ are not touched.

No other site can frame these pages. Every page now answers Content-Security-Policy: frame-ancestors 'self' and X-Frame-Options: SAMEORIGIN, so a page on another site cannot show Gymora in a hidden frame and trick a signed-in person into pressing a button [src: app/index.php:4]. The installer answers frame-ancestors 'none' and X-Frame-Options: DENY [src: app/install/index.php:4]. If you had Gymora framed inside another site of your own, that frame now stays empty; link to it instead.

/mcp refuses other sites. A request whose Origin header names another site gets 403 before anything is read. A desktop or server client sends no Origin and is served as before, and so is a page at Gymora's own address [src: app/src/Mcp.php:272].

Single sign-on. The sign-in now uses PKCE (S256), so an intercepted sign-in code is of no use on its own; a sign-in that comes back to a different browser session is asked to start again [src: app/src/Oidc.php:228]. With Google as the provider, a company address needs a Google Workspace account of that same domain (the token's hd claim), and Google must report the address as verified, so a personal Google account made with a work address no longer gets in [src: app/src/Oidc.php:215]. Gmail addresses work as before, other providers are unchanged, and password sign-in is not affected.

The audit CSV. A cell that starts with a carriage return now opens in a spreadsheet as text, as cells starting with = + - @ or TAB already did [src: app/src/AuditLog.php:138].

Docker. The Dockerfile now installs PHP's zip extension, so a Docker install can make the backup with files [src: app/Dockerfile:7].

Also changed, with no effect on a running install: the corner card on a public demo offers only the purchase, and API.md no longer shows the config example of a product we retired.

← Back to Gymora · Quickstart · API · Test run