Connect your site to your gym

Members sign in with their your gym account. Your site gets a signed answer with their member number. Standard OpenID Connect, so any OIDC library works.

App IDapp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Issuerhttps://movemembers.com
Return addresseshttps://yoursite.com/callback
1

Add the button

Send the member to their gym's sign-in page. PKCE is required for every app, confidential or public.

HTML
<!-- "Join Your Gym" -->
<a href="https://movemembers.com/oauth/authorize?client_id=app_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&response_type=code&scope=openid%20member_number%20profile%20email%20phone%20membership%20bookings%20field%3Avisitor_type&redirect_uri=https://yoursite.com/callback&state=…&nonce=…&code_challenge=…&code_challenge_method=S256&prompt=create"
  style="display:inline-flex;align-items:center;gap:10px;padding:12px 20px;
  border-radius:10px;background:#ffffff;color:#14161b;border:1px solid #e4e6ef;
  font-family:inherit;
  font-size:15px;font-weight:600;text-decoration:none">
  Join Your Gym
</a>
2

Exchange the code on your server

You get an ID token signed by the gym, and the member's details. Never do this exchange from a page: it needs your client secret.

Node.js (openid-client, a certified OIDC client library):

Node.js
const { Issuer } = require("openid-client"); // any certified OIDC client works

const issuer = await Issuer.discover("https://movemembers.com");
const client = new issuer.Client({
  client_id: "app_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  client_secret: process.env.APP_SECRET, // never hard-code this
  redirect_uris: ["https://yoursite.com/callback"],
  response_types: ["code"],
});

// codeVerifier is the one you generated before redirecting the member
const tokenSet = await client.callback(
  "https://yoursite.com/callback",
  params, // req.query, from the redirect
  { code_verifier: codeVerifier, state, nonce },
);
// tokenSet.claims() is verified against the gym's public keys at /oauth/jwks
const member = tokenSet.claims();

PHP:

PHP
// Any PSR-7 HTTP client works; shown here without a library for clarity.
$response = http_post("https://movemembers.com/oauth/token", [
    "grant_type"    => "authorization_code",
    "code"          => $_GET["code"],
    "redirect_uri"  => "https://yoursite.com/callback",
    "client_id"     => "app_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "client_secret" => getenv("APP_SECRET"), // never hard-code this
    "code_verifier" => $_SESSION["code_verifier"],
]);

$tokens = json_decode($response, true);
// Verify $tokens["id_token"] against https://movemembers.com/oauth/jwks (use a JWT/JWKS library,
// never hand-roll signature verification).

The ID token is signed with ES256 and verified against the gym's public keys, published at:

Node.js
GET https://movemembers.com/oauth/jwks
3

Read the claims

The ID token (and GET /oauth/userinfo with the access token) carry only what the gym has checked and the member has approved — never more.

JSON
{
  "sub": "mbr_4Hq9kR2vLpXsT8nZ",
  "member_number": "1042",
  "name": "Nok Suwan",
  "email": "nok@example.com",
  "email_verified": true,
  "phone_number": "+66812345678",
  "membership": { "status": "active", "plan": "Monthly", "ends": "2026-11-01" },
  "fields": { "visitor_type": "…" }
}

Link your own record to "sub", never to the email address: "sub" never changes for this member and this app, even if they change their email. Check the status later with GET /api/v1/members/1042.

"email_verified" is true only once the member has signed in with a code at least once; treat "false" as unverified and never trust the email alone until then.

Sign in button, ready to paste

Three styles: your brand, light and dark. Inline CSS, so it drops into any page without a stylesheet.

1

Your brand

HTML
<a href="https://movemembers.com/oauth/authorize?client_id=app_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&response_type=code&scope=openid%20member_number%20profile%20email%20phone%20membership%20bookings%20field%3Avisitor_type&redirect_uri=https://yoursite.com/callback&state=…&nonce=…&code_challenge=…&code_challenge_method=S256"
  style="display:inline-flex;align-items:center;gap:10px;padding:12px 20px;
  border-radius:12px;background:#2563eb;color:#ffffff;border:none;
  font-family:inherit;
  font-size:15px;font-weight:600;text-decoration:none">
  Continue with Your Gym
</a>
2

Light

HTML
<a href="https://movemembers.com/oauth/authorize?client_id=app_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&response_type=code&scope=openid%20member_number%20profile%20email%20phone%20membership%20bookings%20field%3Avisitor_type&redirect_uri=https://yoursite.com/callback&state=…&nonce=…&code_challenge=…&code_challenge_method=S256"
  style="display:inline-flex;align-items:center;gap:10px;padding:12px 20px;
  border-radius:10px;background:#ffffff;color:#14161b;border:1px solid #e4e6ef;
  font-family:inherit;
  font-size:15px;font-weight:600;text-decoration:none">
  Continue with Your Gym
</a>
3

Dark

HTML
<a href="https://movemembers.com/oauth/authorize?client_id=app_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&response_type=code&scope=openid%20member_number%20profile%20email%20phone%20membership%20bookings%20field%3Avisitor_type&redirect_uri=https://yoursite.com/callback&state=…&nonce=…&code_challenge=…&code_challenge_method=S256"
  style="display:inline-flex;align-items:center;gap:10px;padding:12px 20px;
  border-radius:10px;background:#14161b;color:#ffffff;border:1px solid #2a2d36;
  font-family:inherit;
  font-size:15px;font-weight:600;text-decoration:none">
  Continue with Your Gym
</a>

Join button, ready to paste

Opens the gym's sign-up form directly (prompt=create), then returns the member exactly like the sign-in button. Use this on a "Join Your Gym" call to action.

1

Your brand

HTML
<a href="https://movemembers.com/oauth/authorize?client_id=app_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&response_type=code&scope=openid%20member_number%20profile%20email%20phone%20membership%20bookings%20field%3Avisitor_type&redirect_uri=https://yoursite.com/callback&state=…&nonce=…&code_challenge=…&code_challenge_method=S256&prompt=create"
  style="display:inline-flex;align-items:center;gap:10px;padding:12px 20px;
  border-radius:12px;background:#2563eb;color:#ffffff;border:none;
  font-family:inherit;
  font-size:15px;font-weight:600;text-decoration:none">
  Join Your Gym
</a>
2

Light

HTML
<a href="https://movemembers.com/oauth/authorize?client_id=app_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&response_type=code&scope=openid%20member_number%20profile%20email%20phone%20membership%20bookings%20field%3Avisitor_type&redirect_uri=https://yoursite.com/callback&state=…&nonce=…&code_challenge=…&code_challenge_method=S256&prompt=create"
  style="display:inline-flex;align-items:center;gap:10px;padding:12px 20px;
  border-radius:10px;background:#ffffff;color:#14161b;border:1px solid #e4e6ef;
  font-family:inherit;
  font-size:15px;font-weight:600;text-decoration:none">
  Join Your Gym
</a>
3

Dark

HTML
<a href="https://movemembers.com/oauth/authorize?client_id=app_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&response_type=code&scope=openid%20member_number%20profile%20email%20phone%20membership%20bookings%20field%3Avisitor_type&redirect_uri=https://yoursite.com/callback&state=…&nonce=…&code_challenge=…&code_challenge_method=S256&prompt=create"
  style="display:inline-flex;align-items:center;gap:10px;padding:12px 20px;
  border-radius:10px;background:#14161b;color:#ffffff;border:1px solid #2a2d36;
  font-family:inherit;
  font-size:15px;font-weight:600;text-decoration:none">
  Join Your Gym
</a>

Check a member by number

For a server-to-server check that doesn't need the member present: get an app token with client_credentials, then look up a member by number. This never requires the member's consent, and returns exactly two fields.

1

Get an app token

Node.js
const tokens = await fetch("https://movemembers.com/oauth/token", {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    grant_type: "client_credentials",
    client_id: "app_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    client_secret: process.env.APP_SECRET,
    scope: "lookup",
  }),
}).then((r) => r.json());
2

Look up the member

Node.js
const res = await fetch("https://movemembers.com/api/v1/members/1042", {
  headers: { Authorization: `Bearer ${tokens.access_token}` },
});
// { "member_number": 1042, "active": true }

Nothing else comes back here, whatever the app's other capabilities: this is the one endpoint that works without the member's consent.

Webhooks

member.connected, member.disconnected, member.status_changed, booking.created, booking.cancelled, payment.completed, member.group_changed. Delivered as a signed POST, retried up to 5 times over about 3 hours if your endpoint doesn't answer with a 2xx.

1

Verify the signature

Every delivery carries three headers: X-Webhook-Signature (t=<timestamp>,v1=<hmac>), X-Webhook-Id (unique per attempt, for deduplication) and X-Webhook-Timestamp. Verify on the RAW body you received, never a re-serialized one: reordering keys changes the signature.

The signing secret is shown once, in the app's Webhooks tab, right when you add the address. Store it then: it can't be shown again, only rotated.

Node.js
const crypto = require("node:crypto");

function verifyWebhook(rawBody, header, secret) {
  const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header);
  if (!m) return false;
  const [, timestamp, signature] = m;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`) // the EXACT bytes received, never re-serialized
    .digest("hex");
  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
2

Read the event

JSON
{
  "type": "booking.created",
  "member_number": 1042,
  "sub": "mbr_4Hq9kR2vLpXsT8nZ",
  "class_id": "…",
  "date": "2026-10-03"
}

Book a class

List what's on the schedule, then book or cancel on the member's behalf, with their access token.

1

List the schedule

Node.js
const res = await fetch("https://movemembers.com/api/v1/schedule", {
  headers: { Authorization: `Bearer ${memberAccessToken}` },
});
const classes = await res.json();
2

Book a class

Node.js
const res = await fetch("https://movemembers.com/api/v1/bookings", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${memberAccessToken}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ class_id: classId, date: "2026-10-04" }),
});
if (!res.ok) {
  const { error } = await res.json();
  if (error.code === "booking_refused") showToMember(error.message); // e.g. "class full"
}

A refused booking is not an error to log and hide: "booking_refused" always carries the reason a member would recognize (a full class, a membership issue, a closed window). Show error.message to them, don't swallow it.

3

Cancel a booking

Node.js
await fetch(`https://movemembers.com/api/v1/bookings/${bookingId}`, {
  method: "DELETE",
  headers: { Authorization: `Bearer ${memberAccessToken}` },
});

Show your schedule and prices

Public information, no member sign-in needed: perfect for a site's own class timetable or pricing page.

1

The public schedule

Node.js
const res = await fetch("https://movemembers.com/api/v1/schedule", {
  headers: { Authorization: `Bearer ${appToken}` }, // scope=schedule
});
const classes = await res.json();
2

Plans on public sale

Node.js
const res = await fetch("https://movemembers.com/api/v1/offers", {
  headers: { Authorization: `Bearer ${appToken}` }, // scope=schedule
});
const offers = await res.json();

Recipe: verify identity with a third party, then set a group

A common shape when a gym needs proof of something (a national ID, an age, a certification) before selling certain plans: the check happens entirely outside this API.

1

The member signs in

You get "sub" the usual way (quick start, steps 1–3).

2

Verify with a third party

Send the member to a verification provider of your choice, joined with your own "sub" for this member so you know whose result comes back.

3

Receive the decision on your server

The provider calls YOUR server's webhook with the result. Never read a verification decision sent from a browser page: a page can be edited by whoever is looking at it, so a client-side "verified: true" proves nothing.

4

Set the group

Node.js
await fetch("https://movemembers.com/api/v1/members/1042/group", {
  method: "PUT",
  headers: {
    Authorization: `Bearer ${appToken}`, // scope=groups
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ group: "thai", reason: "ID verified" }),
});

Never send identity data (name, ID number, photo, date of birth) to this API. Send only the group the member has earned; the verification, and everything it was based on, stays entirely on your side.

All endpoints

JSON everywhere. Every write endpoint calls the exact same rules as the gym's own apps (booking windows, waitlists, plan checks): nothing behaves differently because it came from your site.

MethodPathWhat it doesAuth
GET/.well-known/openid-configurationDiscovery document—
GET/oauth/jwksPublic signing keys—
GET/oauth/authorizeStart a sign-in or sign-up—
POST/oauth/tokenExchange a code, refresh, or get an app tokenapp credentials
GET/oauth/userinfoThe signed-in member's claimsmember, Bearer
POST/oauth/revokeRevoke a tokenapp credentials
GET/api/v1/meThe signed-in membermember, Bearer
GET/api/v1/me/bookingsTheir bookingsmember, Bearer
POST/api/v1/bookingsBook a class on their behalfmember, Bearer
DELETE/api/v1/bookings/{id}Cancel a bookingmember, Bearer
GET/api/v1/members/{member_number}Check a member by numberapp, scope=lookup
GET/api/v1/schedulePublic class scheduleapp, scope=schedule
GET/api/v1/offersPlans and products on public saleapp, scope=schedule
GET/api/v1/groupsGroups this app can setapp, scope=groups
PUT/api/v1/members/{member_number}/groupSet (or clear) a member's groupapp, scope=groups

Errors

Every error looks the same: { "error": { "code", "message" } }.

CodeMeaning
invalid_tokenThe bearer token is missing, expired, or revoked.
insufficient_scopeThe token doesn't carry the scope this endpoint needs.
capability_offThe gym hasn't enabled this capability for your app.
plan_requiredThe gym's plan doesn't include connected apps right now. Nothing is deleted; it comes back the moment they upgrade.
not_foundThat resource doesn't exist (or doesn't belong to this gym).
rate_limitedToo many requests. Back off and retry.
booking_refusedThe booking rules refused this request; message explains why (a membership check, a booking window, a full class…).
payment_requiredThis booking needs to be paid before the seat is confirmed. payment_address points to the member's payment page.
no_consentThat member hasn't connected to your app yet.
group_not_allowedThat group isn't one the gym enabled for your app.

Test with your own account

Register http://localhost (with any port) as one of your return addresses while you build: it's the one address the gym accepts over plain http, exactly like every other return address in this system. Add your real production address before going live; the localhost one can stay for the next time you need to test.