GymSysGymSys/ developers
PortalGet started
SDK v1

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.

ScopeWhat it gives you
occupancy.readHow many people are inside right now
events.readRecent entries and exits. Anonymous — no member is identified
org.readThe gym's name, slug and status. Nothing else from its record
plans.readMembership plans on offer
tickets.readTicket types on offer
classes.readClass schedule, with capacity and how many spots are taken
stats.readAttendance statistics — totals and busiest hour, never rows
equipment.readEquipment list with status and category
store.readStore products on public sale
spaces.readRooms and areas
announcements.readActive gym announcements
There is no scope for members, staff, money or doors, and there is no write scope of any kind. That is a boundary, not a v1 limitation.

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.

EndpointScopeReturns
GET /occupancyoccupancy.read{ inside, capacity, updated_at }
GET /events?since=events.readEntry/exit events in a window. No member identifiers
GET /orgorg.read{ id, name, slug, status }
GET /plansplans.readMembership plans
GET /ticketstickets.readTicket types
GET /classes?from=&to=classes.readSessions with capacity and booked_count
GET /statsstats.readAggregates over a rolling 7 days
GET /equipmentequipment.readEquipment list
GET /storestore.readProducts
GET /spacesspaces.readRooms and areas
GET /announcementsannouncements.readActive 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.