API v1 · OpenID Connect
เชื่อมเว็บไซต์ของคุณ กับยิมของคุณ
ให้สมาชิกเข้าสู่ระบบด้วยบัญชียิมของพวกเขา ตรวจสอบแพ็กเกจ จองคลาส และแสดงตารางของคุณบนเว็บไซต์ของคุณเอง ด้วย OpenID Connect มาตรฐาน REST API ขนาดเล็ก และ webhook ที่เซ็นกำกับ
curl https://movemembers.com/.well-known/openid-configurationconst { 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{ "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_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxIssuerhttps://movemembers.comReturn 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.
<!-- "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
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
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.
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
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
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
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
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
Read the event
{ "type": "booking.created", "member_number": 1042, "sub": "mbr_4Hq9kR2vLpXsT8nZ", "class_id": "…", "date": "2026-10-03"}
สูตรสำเร็จ
สูตรสำเร็จ
06 · Your app
Recipe: link a record on your site to a member
Any site that owns its own records (a dog, a locker, a car, a booking of its own) can attach them to a gym member without ever storing their password.
- 1
The member signs in
They tap "Join Your Gym", sign in (or sign up) at the gym, and approve what your app asks for.
- 2
Your server gets sub
You exchange the code for an ID token, and read "sub" (a stable id for this member and this app, e.g. "mbr_4Hq9kR2vLpXsT8nZ").
- 3
Store sub as a foreign key
Save "sub" on your own record (the dog, the locker, the car), never the email address, which can change.
- 4
Re-check later
GET /api/v1/members/1042Authorization: Bearer <app token, scope=lookup>
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
List the schedule
const res = await fetch("https://movemembers.com/api/v1/schedule", { headers: { Authorization: `Bearer ${memberAccessToken}` },});const classes = await res.json(); - 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
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
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
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
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
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.
| Method | Path | What it does | Auth |
|---|---|---|---|
| GET | /.well-known/openid-configuration | Discovery document | none |
| GET | /oauth/jwks | Public signing keys | none |
| GET | /oauth/authorize | Start a sign-in or sign-up | none |
| POST | /oauth/token | Exchange a code, refresh, or get an app token | app credentials |
| GET | /oauth/userinfo | The signed-in member's claims | member, Bearer |
| POST | /oauth/revoke | Revoke a token | app credentials |
| GET | /api/v1/me | The signed-in member | member, Bearer |
| GET | /api/v1/me/bookings | Their bookings, and their plans (classes left, dates, amount due) | member, Bearer |
| POST | /api/v1/bookings | Book a class on their behalf | member, Bearer |
| DELETE | /api/v1/bookings/{id} | Cancel a booking | member, Bearer |
| GET | /api/v1/members/{member_number} | Check a member by number | app, scope=lookup |
| POST | /api/v1/members/verify | Check a member number and an email together | app, scope=lookup |
| GET | /api/v1/schedule | Public class schedule | app, scope=schedule |
| GET | /api/v1/offers | Plans and products on public sale | app, scope=schedule |
| GET | /api/v1/groups | Groups this app can set | app, scope=groups |
| PUT | /api/v1/members/{member_number}/group | Set (or clear) a member's group | app, 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.
| Code | Meaning |
|---|---|
invalid_request | The 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_token | The bearer token is missing, expired, or revoked. |
insufficient_scope | The token doesn't carry the scope this endpoint needs. |
capability_off | The gym hasn't enabled this capability for your app. |
plan_required | The gym's plan doesn't include connected apps right now. Nothing is deleted; it comes back the moment they upgrade. |
not_found | That resource doesn't exist (or doesn't belong to this gym). |
rate_limited | Too many requests. Wait the number of seconds in the Retry-After header, then retry (see Rate limits). |
booking_refused | The 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_required | This 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_consent | That member hasn't connected to your app yet. |
group_not_allowed | That 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.
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.