เริ่มฟรี

API v1 · OpenID Connect

เชื่อมเว็บไซต์ของคุณ กับยิมของคุณ

ให้สมาชิกเข้าสู่ระบบด้วยบัญชียิมของพวกเขา ตรวจสอบแพ็กเกจ จองคลาส และแสดงตารางของคุณบนเว็บไซต์ของคุณเอง ด้วย OpenID Connect มาตรฐาน REST API ขนาดเล็ก และ webhook ที่เซ็นกำกับ

$curl https://movemembers.com/.well-known/openid-configuration
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
200 OKGET /oauth/userinfo
{  "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": "…" }}

01 · Your app

Connect your site to your gym

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

App IDapp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxIssuerhttps://movemembers.comReturn addresseshttps://yoursite.com/callback
  1. 1

    Add the button

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

    <!-- "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>

    What the member sees: the sign-in screen is skipped while they are still signed in to your gym on that device, and the approval screen is shown once per member and app. It comes back only if you send prompt=consent, ask for a wider scope, or the app is disconnected. Avoid prompt=login, prompt=consent and max_age unless you really need them: each one brings a screen back.

  2. 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):

    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 memberconst 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/jwksconst member = tokenSet.claims();

    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:

    GET https://movemembers.com/oauth/jwks
  3. 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.

    {  "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.

02 · Your app

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. 1

    Your brand

  2. 2

    Light

  3. 3

    Dark

03 · Your app

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. 1

    Your brand

  2. 2

    Light

  3. 3

    Dark

04 · Your app

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, or by number and email. Neither call requires the member's consent, and neither returns anything about the member beyond whether their membership is active.

  1. 1

    Get an app token

    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. 2

    Look up the member

    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 endpoint and the one below are the only ones that work without the member's consent.

  3. 3

    Or check a number and an email together

    When a visitor types their gym member number and email into a form on your site, check both at once, from your server. Send them in the body, never in the address: an address ends up in server logs, a body does not.

    const res = await fetch("https://movemembers.com/api/v1/members/verify", {  method: "POST",  headers: {    Authorization: `Bearer ${tokens.access_token}`,    "Content-Type": "application/json",  },  body: JSON.stringify({ member_number: 1042, email: form.email }),});// { "active": true }

    The answer is always 200 with one field, "active". It is false when the number doesn't exist, when the email doesn't match the one on file, when the membership isn't active, or when the member left: the answer never tells you which, so nobody can use your form to find out who is a member. Only a malformed body (a missing field, an extra field, a number that isn't a number) answers 400 invalid_request.

    A number and an email prove that someone knows them, not that they are the member. To be sure it is the account holder acting, use the sign-in button: the member signs in at your gym and your server gets their "sub".

05 · Your app

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. 1

    Verify the signature

    Every delivery carries three headers: X-Webhook-Signature (t=<timestamp>,v1=<hmac>), X-Webhook-Id (one value per event, repeated on every retry of that delivery) and X-Webhook-Timestamp (the same t, in seconds). Verify on the RAW body you received, never a re-serialized one: reordering keys changes the signature.

    Check the timestamp too: refuse a delivery signed more than 5 minutes ago, or someone who captures one delivery can replay it forever and the signature will still be valid. Every retry is signed again with a fresh timestamp, so a genuine retry always passes. Then use X-Webhook-Id to deduplicate: remember the ids you have processed (for a day, since retries span about 3 hours) and ignore a repeat.

    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.

    const crypto = require("node:crypto"); const TOLERANCE_SECONDS = 300; // every retry is signed again, so a real delivery is always fresh function verifyWebhook(rawBody, header, secret) {  const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header);  if (!m) return false;  const [, timestamp, signature] = m;  // Replay protection: refuse anything signed more than 5 minutes ago (or in the future).  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;  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));} // Then deduplicate on the X-Webhook-Id header: keep the id of every delivery you have processed// (for a day, retries span about 3 hours) and ignore an id you have already seen.
  2. 2

    Read the event

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

สูตรสำเร็จ

สูตรสำเร็จ

07 · Your app

Book a class

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

  1. 1

    List the schedule

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

    Book a class

    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. 3

    Cancel a booking

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

08 · Your app

Show your schedule and prices

Public information, no member sign-in needed: perfect for a site's own class timetable or pricing page. Both calls use the same scope, schedule: /schedule lists the classes and /offers lists the plans and products on public sale, so one app token with scope=schedule reads both.

  1. 1

    The public schedule

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

    Plans on public sale

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

09 · Your app

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. 1

    The member signs in

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

  2. 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. 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. 4

    Set the group

    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.

10 · Reference

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 documentnone
GET/oauth/jwksPublic signing keysnone
GET/oauth/authorizeStart a sign-in or sign-upnone
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 bookings, and their plans (classes left, dates, amount due)member, 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
POST/api/v1/members/verifyCheck a member number and an email togetherapp, 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

11 · Reference

Errors

Every /api/v1 error looks the same: { "error": { "code", "message", "request_id" } }. The /oauth endpoints keep the standard OAuth shape, { "error", "error_description" }, so OAuth libraries work as they are, and carry request_id at the top level. request_id is a short opaque reference, also sent as the X-Request-Id header: quote it when you ask for help, it finds your call in the log.

CodeMeaning
invalid_requestThe request itself is malformed: an unknown or mistyped field, a missing parameter, a body that is too large. The message says which. On the /oauth endpoints it is the standard OAuth invalid_request.
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. Wait the number of seconds in the Retry-After header, then retry (see Rate limits).
booking_refusedThe booking rules refused this request; message explains why (a membership check, a booking window, a full class…). When the member has no plan that covers the class, the message starts with membership_ (for example membership_none or membership_expired) and NO booking is created: read GET /me/bookings, key memberships (classes_left, start_date, end_date, amount_due), and send the member to buy a plan. Only gyms that choose to hold a seat until it is paid answer payment_required instead.
payment_requiredThis booking needs to be paid before the seat is confirmed. The error object carries payment_address, the link to the member's payment page: send the member there, and the seat is confirmed once they have paid.
no_consentThat member hasn't connected to your app yet.
group_not_allowedThat group isn't one the gym enabled for your app.

12 · Reference

Rate limits

Limits are counted per minute. When you hit one the API answers 429 with code rate_limited, a Retry-After header (the seconds left in the current minute) and a request_id: wait that long, then retry.

CallsPer app / minPer IP / min
Read: /api/v1/me, /me/bookings, /schedule, /offers, /groups300300
Write: POST /bookings, DELETE /bookings/{id}, PUT /members/{member_number}/group120120
Member check: GET /members/{member_number}, POST /members/verify (one shared budget)120120
/oauth/authorize6030
/oauth/token6060
/oauth/userinfo and /oauth/revoke120120
Discovery document and /oauth/jwksnone120

Each class has its own budget, so heavy reading never uses up your bookings. On top of that, all the apps of one gym share 600 calls a minute on /api/v1: it only matters if several integrations add up or one misbehaves. Cache what rarely changes (the schedule, the offers) instead of asking for it on every page view.

13 · Reference

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.

มอบซอฟต์แวร์ที่ยิมของคุณสมควรได้

ใช้ฟรีได้สูงสุด 20 สมาชิก เริ่มใช้งานได้ภายในครึ่งวัน ยกเลิกเมื่อไรก็ได้