Documentation
Everything an app can do, and nothing it can't. The whole API is read-only: there is no write scope, so no app — yours included — can change a gym's data.
Quickstart
Host a page on any HTTPS URL, add one script tag, and you have a working app. There is no build step, no package to install and no key to manage.
<script src="https://api.gymatic.eu/sdk/v1/gymsys-app-sdk.js"></script>
<script>
GymSys.init().then(function (ctx) {
document.title = ctx.org.name;
return GymSys.api.getOccupancy();
}).then(function (res) {
document.body.textContent = res.data.inside + " people inside";
GymSys.ui.autoResize();
});
</script>Submit that URL in the portal. Once a GymSys admin approves it, any gym can install it and it renders inside their dashboard.
How auth works
You never handle a password or an API key. Your page runs in a sandboxed iframe inside the gym's portal, and the portal hands it a session token over postMessage. The SDK does that handshake and keeps the token fresh — it lives 300 seconds and is renewed for you.
If you have a backend and want to trust what the browser tells it, verify the token yourself. It is an RS256 JWT; fetch our public keys from /app-api/.well-known/jwks.json, or post it to POST /app-api/verify-session and read the answer.
About identifying people. The token's sub is a pseudonymous id derived from (your app, that user). It is stable for you, so it is the right thing to key a subscription or saved preferences on — but it is different in every other app, so it cannot be used to join your data with anyone else's. You never receive a GymSys user id, a name or an email.
Scopes
You request scopes when you register the app; the gym sees them in plain language and approves them at install. A gym may grant fewer than you asked for, so check GymSys.can("stats.read") or handle the error — a call outside your grant returns 403 with the missing scope named.
| Scope | What it gives you |
|---|---|
occupancy.read | How many people are inside right now |
events.read | Recent entries and exits. Anonymous — no member is identified |
org.read | The gym's name, slug and status. Nothing else from its record |
plans.read | Membership plans on offer |
tickets.read | Ticket types on offer |
classes.read | Class schedule, with capacity and how many spots are taken |
stats.read | Attendance statistics — totals and busiest hour, never rows |
equipment.read | Equipment list with status and category |
store.read | Store products on public sale |
spaces.read | Rooms and areas |
announcements.read | Active gym announcements |
API reference
Base URL https://api.gymatic.eu/app-api. Every response is { "data": … }. The SDK wraps all of these; the raw paths are here for backends using a server key.
| Endpoint | Scope | Returns |
|---|---|---|
GET /occupancy | occupancy.read | { inside, capacity, updated_at } |
GET /events?since= | events.read | Entry/exit events in a window. No member identifiers |
GET /org | org.read | { id, name, slug, status } |
GET /plans | plans.read | Membership plans |
GET /tickets | tickets.read | Ticket types |
GET /classes?from=&to= | classes.read | Sessions with capacity and booked_count |
GET /stats | stats.read | Aggregates over a rolling 7 days |
GET /equipment | equipment.read | Equipment list |
GET /store | store.read | Products |
GET /spaces | spaces.read | Rooms and areas |
GET /announcements | announcements.read | Active announcements |
GET /install | — none — | Which gym installed you, when, with which scopes |
GymSys.api.getClasses("2026-09-01", "2026-09-08")
.then(function (res) {
res.data.forEach(function (s) {
console.log(s.starts_at, s.capacity - s.booked_count, "spots left");
});
});Storage
A key/value store scoped to one installation — use it for settings, not as a database. 128 keys, 64KB each. It survives an uninstall for 30 days, so a gym that reinstalls quickly keeps its configuration.
GymSys.storage.set("layout", { columns: 3, showClasses: true });
GymSys.storage.get("layout").then(function (v) { … });
GymSys.storage.list();
GymSys.storage.remove("layout");Webhooks
Register a URL and we will POST to it when something changes, so you do not have to poll. Events: plans, tickets, classes, plus the two lifecycle events below.
If you charge for your app, these are the ones you need. install.created fires when a gym installs you and install.removed when it uninstalls — that is your signal to start and stop billing. Both carry the install id, the gym and the granted scopes, and never any personal data.
Every delivery is signed. Verify X-GymSys-Signature as sha256=HMAC-SHA256(your app secret, raw body) before trusting it. Compare in constant time, and reject anything unsigned.
POST /app-api/webhooks { "url": "https://you.example/hook",
"events": ["classes", "install.removed"] }Server keys
If your app has its own backend, tick external service when registering. Each gym that installs you then issues a key, shown to them once, which your servers send as X-API-Key against the same read endpoints.
One key per installation: it identifies which gym you are calling for, it stops working the moment that gym uninstalls, and it stops working while their GymSys subscription is lapsed.
Review rules
A human reviews every developer account and every app version before a gym can install it. What we check: the URL is HTTPS and reachable, and the scopes match what the app plausibly needs.
Changing your app's URL or its scopes sends it back for review — but your currently approved version keeps running the whole time. Nothing breaks for gyms while you wait. Adding scopes also requires each gym to accept the new permissions; until they do, your app keeps working with the ones they already granted.
A complete app
Save this as a single HTML file, host it on any HTTPS URL, and submit that URL. It is a full occupancy board: live count, the gym's own theme, a stored setting and correct frame sizing.
<!doctype html>
<meta charset="utf-8">
<title>Occupancy Board</title>
<style>
body { margin:0; font:16px system-ui; display:grid; place-items:center; height:100vh; }
#n { font-size: 22vw; font-weight: 800; line-height: 1; }
#s { opacity: .6; }
</style>
<div>
<div id="n">—</div>
<div id="s">connecting…</div>
</div>
<script src="https://api.gymatic.eu/sdk/v1/gymsys-app-sdk.js"></script>
<script>
var n = document.getElementById("n"), s = document.getElementById("s");
GymSys.init().then(function (ctx) {
// The gym's own colours, so the board looks native on their screen.
document.body.style.background = ctx.theme.dark;
document.body.style.color = ctx.theme.lime;
s.textContent = ctx.org.name;
// A per-install setting: remember whether this screen wants big text.
return GymSys.storage.get("big").then(function (big) {
if (big === false) n.style.fontSize = "10vw";
});
}).then(function () {
GymSys.ui.autoResize();
tick();
setInterval(tick, 15000);
})["catch"](function (err) {
s.textContent = err.message;
});
function tick() {
GymSys.api.getOccupancy().then(function (res) {
n.textContent = res.data.inside;
})["catch"](function (err) {
// A missing scope tells you exactly which one to request.
s.textContent = err.missingScope ? "Need " + err.missingScope : "offline";
});
}
</script>Requires one scope: occupancy.read.