MyGymDesk Website API β Integration Guide
Audience: the web developer building or maintaining a MyGymDesk customer's website. Version: 1.6 (2026-08-08) Β· See Changelog.
This guide covers the MyGymDesk Website API: HTTPS endpoints your gym's website can call to capture leads, display memberships and class timetables, and take real bookings with payment. Everything is authenticated with a single API key the gym owner generates from their MyGymDesk dashboard.
New in 1.6 β plan entitlement is now enforced per request. The Leads API (
capture-website-lead) works on the Pro plan and above. Every other endpoint requires the Enterprise plan or the Full Website API add-on (βΉ500/month Β· βΉ5,000/year Β· βΉ12,000/3-year, available to Pro gyms under Settings β Billing & Plan β Add-ons). A request without the entitlement gets403 plan_upgrade_required(see Β§3) and does not count against the rate-limit budget. If the gym's subscription lapses, access ends after the 7-day grace window; the add-on has the same 7-day grace past its end date. The 1.5 footnote that said "the API itself doesn't re-check the plan today β¦ don't rely on that" has now been cashed in.
New in 1.5: the website can now take money end-to-end for two more things β a shop checkout (
website-shop-order-createβwebsite-shop-order, Β§5.10βΒ§5.11) and a membership purchase with automatic member creation + Member App activation (website-membership-orderβwebsite-membership-purchase, Β§5.12βΒ§5.13). Both finalize endpoints are budget-exempt like the booking finalizes. A repeat purchase by an existing member is a renewal (a new subscription), never an error. Coupons are not yet supported on these endpoints. See the Changelog.From 1.2: your API key defines which branch it can see and act on, for reads as well as writes (Β§9). Every error has the same
{error, message}shape, every429tells you how long to wait, and leaving Allowed Origins empty means unrestricted rather than blocked.
#Contents
- What you can build
- Getting your API key
- Authentication & base URL
- Quick start (2 minutes)
- Endpoint reference
- Taking a booking end-to-end
- Rate limits
- CORS, origins & calling from the browser
- Multi-location (branches)
- Recipes
- What happens after a call
- Troubleshooting
- Current limitations
- Versioning & compatibility
- Changelog
#1. What you can build
#Endpoint inventory
All endpoints live under https://db.mygymdesk.in/functions/v1/.
ΒΉ (1.6) Enforced per request since 2026-08-15. "Pro+" = Pro or Enterprise plan (or the add-on). "Enterprise / add-on" = the Enterprise plan or the Full Website API add-on (βΉ500/month, available to Pro gyms). A key whose gym lacks the entitlement gets 403 plan_upgrade_required β the key itself stays valid, and the Leads endpoint keeps working on Pro. Generating a key still requires the Pro plan (the settings tab is Pro-and-above). Suspended gyms and gyms past their post-expiry grace window fail every endpoint with the same code.
Β² All endpoints share one hourly budget per key (default 30/hour), not 30 each. See Rate limits β cache the display endpoints and ask MyGymDesk to raise the limit before launching a public site.
Β³ (1.4) The two booking-finalize endpoints don't count against (or check) the budget β a 429 there would land after the customer's payment was captured. They still authenticate and still enforce the origin allow-list. The order-create call stays on the budget; it is the throttle point before money moves.
Not covered here: MyGymDesk also hosts a ready-made sign-up page at
mygymdesk.in/{gym-slug}(trials, plan purchase, coupons). Those endpoints are internal to that page and are not a supported public API. To offer sign-up on the gym's own domain, link or embed the hosted page.
#2. Getting your API key
The gym owner does this once:
- Settings β Growth & Apps β Integrations β Website Integration tab (labelled "Website Integration" in the app; older versions of this guide called it "Website Lead Capture").
- A key is created automatically the first time the page opens. It looks like:
(code
mgd_live_<32 lowercase letters and digits>mgd_live_+ 32 lowercase alphanumeric characters, 41 total.) - Turn the toggle ON. New keys start inactive β until the switch is on, every request returns
403and the configuration fields stay hidden. - Copy the key immediately. It's stored only as a hash and can never be shown again β the field afterwards displays a placeholder. If it's lost, use Regenerate Key.
#Configuration (same screen)
A gym has exactly one key today. The settings screen creates a key only when none exists and offers no "add key" and no delete. A multi-location gym therefore chooses between a branch-scoped key (one branch's data only) and a tenant-wide key (every branch). If you want one key per branch β a separate key for each branch's website β ask MyGymDesk support: the API already supports it, only the self-serve screen doesn't.
#Rotating & disabling
- Regenerate Key replaces the key immediately β no grace period, no dual-key window. Deploy the new key first or accept a brief outage.
- The toggle disables the key without destroying it; switching it back on restores the same key.
- Regenerating preserves location, rate limit, allowed origins, and lifetime counters β only the key changes.
#Keep the key server-side
The key authenticates as the gym. Whoever holds it can read the gym's timetable and pricing, write leads into their CRM, and create bookings. It is a credential, not a public identifier β treat it exactly like a database password:
- Store it in an environment variable or secret manager. Never a public repo, never a Postman collection or spreadsheet that gets emailed around β in practice that is how keys actually leak.
- It is shown once, at creation, and stored only as a hash. Neither the gym nor MyGymDesk support can retrieve it afterwards; the only recovery is Regenerate, which kills the old key immediately.
- Prefer proxying through your own backend rather than shipping the key in browser JavaScript. Browser calls now work (see Β§8), but anyone who reads the key can create leads and exhaust the gym's rate limit. Payments can no longer be forged (the server verifies every capture), so a leaked key cannot mint free bookings β but privacy is still the reason to keep it on your server.
- Prefer the
x-mgd-api-keyheader over the?key=query parameter. Query strings land in logs andRefererheaders.
#3. Authentication & base URL
Base URL: https://db.mygymdesk.in/functions/v1/ β always. Do not call the underlying *.supabase.co host.
Present your key either way (header strongly preferred):
x-mgd-api-key: <your key>?key=<your key>No other credential is needed β no Authorization, no Supabase apikey. The key alone identifies the gym.
#Authentication failures
All twelve endpoints return these identically β same status, same error code, same message. That was not true before 1.2; if you wrote code that string-matches older wording ("Missing or invalid API key" as the error value, or a "Unauthorized" message on bookings), it needs updating.
#The error shape
Every non-2xx response from every endpoint is:
{ "error": "snake_case_code", "message": "Human-readable sentence" }Branch on error, never on message β codes are stable, wording may be improved.
This is not a style preference. The same
errorcode deliberately carries differentmessagetext on different endpoints:method_not_allowedreads "Method not allowed" on the display endpoints and "POST required" on the booking ones, andinternal_errorhas several wordings depending on which step failed. The code is the contract; the sentence is for humans reading logs. Matching onmessagewill break.
A 429 adds one more field:
{ "error": "rate_limit_exceeded", "message": "Rate limit exceeded. Try again later.", "retry_after_seconds": 1620 }retry_after_seconds is seconds until the current hour-window resets. It is a body field only β there is no Retry-After header.
#4. Quick start (2 minutes)
Put the key in your shell first β never paste it inline, it lands in your shell history:
export MGD_KEY="β¦the key the gym gave youβ¦"Send a test lead:
curl -i -X POST "https://db.mygymdesk.in/functions/v1/capture-website-lead" \
-H "x-mgd-api-key: $MGD_KEY" \
-H "content-type: application/json" \
-d '{
"name": "Test Visitor",
"phone": "9876543210",
"email": "[email protected]",
"interest": "Personal Training",
"source_details": "https://yourgym.com/contact"
}'Expected 201:
{ "success": true, "message": "Lead captured successfully", "lead_id": "β¦", "action": "created" }The lead is now in the gym dashboard under Leads β Enquiries. Send the same phone again β 200 with "action":"updated" (MyGymDesk de-duplicates by phone).
Then read some pricing back:
curl "https://db.mygymdesk.in/functions/v1/website-services?resource=plans" \
-H "x-mgd-api-key: $MGD_KEY"Each call consumed one of the gym's 30 hourly requests β don't loop this.
#5. Endpoint reference
#Errors common to the display endpoints
website-classes and website-services share these (in addition to the auth failures above):
unknown_resourcewasunknown resource(with a space) before 1.2. Every error code is nowsnake_case.
#5.1 POST /capture-website-lead
Creates a lead in the CRM, or records a re-enquiry against an existing one.
Request β JSON, max 5 KB. Several fields accept aliases (first non-empty wins):
Send all values as JSON strings β a number (
"phone": 9876543210) or an array/object causes a500. Input is sanitised (HTML tags and< > ' " \;` stripped) and over-length values truncated, all silently β validate in your own form if that matters.
Success β two codes, both success:
(1.4)
location_id/location_namein the success body confirm which branch the lead was filed on (they reflect the fallback too, not just an explicitlocation_idin the request). Both arenullonly for a gym with no active branches.
β οΈ Treat
200and201both as success. A repeat enquiry is the normal case for a real website β the same person fills the form twice, or a returning visitor enquires again. Code that only accepts201will report false failures to real customers. If you need to distinguish, readaction.
Errors:
De-duplication: matched on the last 10 digits of phone, per gym. +91 98765 43210, 09876543210, 9876543210 are the same person.
Try it:
curl -i -X POST "https://db.mygymdesk.in/functions/v1/capture-website-lead" -H "x-mgd-api-key: $MGD_KEY" -H "content-type: application/json" -d '{"name":"Asha Menon","phone":"9876543210","email":"[email protected]",
"interest":"Personal Training","source":"website",
"source_details":"https://yourgym.com/contact","city":"Bengaluru"}'#5.2 GET /website-services?resource=plans β membership & package pricing
The endpoint for pricing cards. (Memberships come from website-services, not website-classes.)
{
"plans": [
{
"id": "β¦",
"name": "Annual Unlimited",
"price": 24000,
"currency": "INR",
"interval": "year",
"intervalLabel": "per year",
"durationDays": 365,
"description": "β¦",
"features": ["All group classes", "Locker included"],
"featured": true,
"displayOrder": 1,
"locationId": null,
"locationName": null
}
]
}The list merges membership plans (must be published to self-serve by the owner) with service packages (session bundles). PT plans and class packages are not included.
Try it:
curl "https://db.mygymdesk.in/functions/v1/website-services?resource=plans" -H "x-mgd-api-key: $MGD_KEY"#5.3 GET /website-services?resource=catalog β bookable services
{
"services": [
{ "id":"β¦", "name":"Sports Massage", "category":"Recovery",
"durationMin":45, "capacity":1, "priceMember":1200, "priceNonMember":1500,
"currency":"INR", "requiresStaff":true, "description":"β¦",
"locationId":"β¦", "locationName":"Main Branch" }
]
}Only services that are active and published. Sorted by the owner's display order, then name.
Try it:
curl "https://db.mygymdesk.in/functions/v1/website-services?resource=catalog" -H "x-mgd-api-key: $MGD_KEY"
# service timetable (same shape as classes, with `category` instead of `sport`)
curl "https://db.mygymdesk.in/functions/v1/website-services?resource=sessions" -H "x-mgd-api-key: $MGD_KEY"#5.4 GET /website-classes?resource=catalog β class types
{
"classes": [
{ "id":"β¦", "name":"Vinyasa Flow", "sport":"Yoga", "intensity":2,
"durationMin":60, "description":"β¦", "capacity":20,
"priceMember":500, "priceNonMember":500, "currency":"INR",
"locationId":null, "locationName":null }
]
}intensity:1low /2medium /3high (anything unrecognised β2).- Classes have one price, so
priceMember==priceNonMember.0= no price set. sportis the class type's category and is the empty string when unset β nevernull. Note the asymmetry with services, whosecategoryisnullwhen unset. Treat both as "no category" rather than testing one way for both.- Class types are tenant-wide β
locationIdis always null. A specific session carries a branch (below).
Try it:
curl "https://db.mygymdesk.in/functions/v1/website-classes?resource=catalog" -H "x-mgd-api-key: $MGD_KEY"#5.5 GET /website-classes?resource=sessions β timetable
Returns the gym's weekly recurring schedule, not a dated calendar.
{
"sessions": [
{ "id":"β¦", "templateKey":"1-0700-β¦", "dayOfWeek":1, "startTime":"07:00",
"durationMin":60, "name":"Vinyasa Flow", "sport":"Yoga",
"instructorName":"Priya Sharma", "instructorAvatarUrl":null, "intensity":2,
"spotsTotal":20, "spotsBooked":6, "description":"β¦", "capacity":20,
"priceMember":500, "priceNonMember":500, "currency":"INR",
"locationId":"β¦", "locationName":"Main Branch" }
]
}Read this carefully β it's the least obvious endpoint:
- Scheduled sessions in the next 90 days collapse to one row per weekly slot (same weekday + start time + class type).
idis the next upcoming real occurrence and is what you pass to booking. It changes over time as occurrences pass β never cache it; re-fetch immediately before booking.templateKeyis not bookable β it's a stable React key only.dayOfWeek:0Sunday β¦6Saturday.startTimeis"HH:MM"in the gym's local time (no timezone is returned β hard-code the gym's).- The 90-day window is measured in the gym's own calendar day, not UTC (fixed in 1.3). Before that it used the UTC date, so a gym west of Greenwich lost its own remaining evening sessions once UTC rolled over β a New York timetable went a day short after 19:00 local.
- Sorted by
dayOfWeek, thenstartTime. locationId/locationName= the session's branch. Filter with?location_id=.
website-services?resource=sessions returns the same shape for services, with category in place of sport and no intensity.
Try it:
curl "https://db.mygymdesk.in/functions/v1/website-classes?resource=sessions" -H "x-mgd-api-key: $MGD_KEY"#5.6 GET /website-session-price β authoritative price
A read-only price check. Bookings resolve and verify the price server-side regardless, so this is optional β use it to render the amount before sending the customer to checkout.
There is no member price on this API. It is anonymous β it has no way to check whether the person in front of your form is actually a member β so every quote and every charge uses the gym's non-member rate.
is_membertruthy withbooking_type=servicereturns422 member_pricing_unsupportedon this endpoint and onwebsite-booking-order, rather than quoting a rate the booking would then refuse. A member's own rate applies in the member portal, where they are signed in β there is no way to obtain it through the website API today. (Changed in 1.3.)
Try it (take a session_id from a sessions response):
curl "https://db.mygymdesk.in/functions/v1/website-session-price?session_id=$SESSION_ID&booking_type=class" -H "x-mgd-api-key: $MGD_KEY"{ "session_id":"β¦", "booking_type":"class", "amount":500, "currency":"INR",
"valid":true, "location_id":"β¦" }amountis in major units (rupees, not paise) β unlikewebsite-booking-order, which returns paise.location_idis the session's branch. It isnullonly for a branchless session, which only a tenant-wide key can reach β a branch key gets403 session_out_of_scopeinstead (Β§9). (Added in 1.2; scoping tightened in 1.3.)
valid:falsecomes back with HTTP200β it means the session exists but has no price set. Branch onvalid, not on the status code.
Errors:
#5.7 POST /website-booking-order β create a Razorpay order
Razorpay is order-first: the order is minted server-side so the amount comes from the gym's own data, never the client. Call this, run Razorpay Checkout with the result, then call the booking endpoint. PayPal callers skip this endpoint (they capture client-side β see Β§6).
Request β JSON:
customerdoes not prefill Checkout. Through 1.2 this table said it did; it never could β prefill happens in your browser call to Razorpay Checkout, not in this server-side endpoint. What the fields actually do now is get stamped onto the gateway order (customer_name/customer_phone/customer_emailin the order notes) so the gym can match a payment in their Razorpay dashboard to a person. Through 1.2,nameand
Success 200:
{
"order_id": "order_TFkβ¦",
"amount": 50000,
"currency": "INR",
"key_id": "rzp_live_β¦",
"test_mode": false,
"session_id": "β¦",
"booking_type": "class",
"collection_method": "own_pg"
}Try it:
curl -X POST "https://db.mygymdesk.in/functions/v1/website-booking-order" -H "x-mgd-api-key: $MGD_KEY" -H "content-type: application/json" -d '{"session_id":"'"$SESSION_ID"'","booking_type":"class",
"customer":{"name":"Asha Menon","phone":"9876543210","email":"[email protected]"}}'amountis in MINOR units (paise) β feed it straight to Razorpay Checkout.key_idis the gym's Razorpay checkout key (their own key, or their connected-account public token). It is nevernullon a200β if the gym's rail can't produce a usable key you get a503instead, and no order is minted. Through 1.2 this could return200withkey_id: null, which Checkout cannot open, after the order had already been created. (Fixed in 1.3.)collection_methodisown_pgoroauthβ informational.
Errors (all {"error":"<code>","message":"β¦"} β branch on error):
#5.8 POST /website-class-booking and POST /website-service-booking
Records a booking after payment. MyGymDesk verifies the payment against the gateway before writing anything β the amount is resolved from the gym's data and the capture is checked on the gym's own account. A forged or replayed capture is rejected and nothing is created.
Request β JSON:
{
"session_id": "β¦",
"customer": { "name": "Asha Menon", "phone": "+91 98765 43210", "email": "[email protected]" },
"payment": {
"gateway": "razorpay",
"order_id": "order_TFkβ¦",
"capture_id": "pay_TFkβ¦",
"signature": "β¦"
}
}Success 200:
{
"ok": true,
"booking_id": "β¦",
"payment_id": "β¦",
"member_id": "β¦",
"lead_id": null,
"status": "confirmed",
"amount_charged": 500,
"currency": "INR",
"location_id": "β¦",
"location_name": "Main Branch"
}amount_charged/currencyare the server-resolved figures β always the gym's non-member rate (Β§5.6).lead_idis nullable β populated only when the booking created a brand-new member; for a returning member it'snull.payment_idis the payment row recorded for this booking. Every booking through this API is a paid booking β a zero-priced session is refused with422 session_not_pricedand never reaches the booking step β so there is no "free booking with no payment row" case. (Through 1.2 this guide claimed there was; there isn't. Read the field defensively anyway.)booking_idandmember_idare always present.location_id/location_name= the branch the booking was filed against. That is the session's own branch; for a tenant-wide key booking a branchless session it falls back to the gym's primary active branch, so a booking is never filed with no branch at all (which would make it invisible on every per-branch view).
customer.phonemust be a real, dialable number for that gym's country. Beyond being non-empty it has to normalize to E.164 β for an Indian gym that means a 10-digit mobile starting6β9, with or without a+91/91prefix and any spacing you like (98765 43210,+91 98765 43210,919876543210all work). A number that can't be normalized is400 invalid_phone, and no booking is created. This rule has always been enforced; it was simply undocumented before 1.3. Note it is stricter thancapture-website-lead, which accepts any 8β15 digits.Since 1.3 this check runs before the payment is verified, so a bad phone costs the customer nothing. Previously it fired after verification β the capture was confirmed and the booking was then refused, leaving money taken against no booking.
Try it (after a real capture β this call is rejected without one):
curl -X POST "https://db.mygymdesk.in/functions/v1/website-class-booking" -H "x-mgd-api-key: $MGD_KEY" -H "content-type: application/json" -d '{"session_id":"'"$SESSION_ID"'",
"customer":{"name":"Asha Menon","phone":"+91 98765 43210","email":"[email protected]"},
"payment":{"gateway":"razorpay","order_id":"'"$ORDER_ID"'",
"capture_id":"'"$PAYMENT_ID"'","signature":"'"$SIGNATURE"'"}}'Errors (all {"error":"<code>","message":"β¦"} β branch on error):
#5.9 GET /website-products β shop catalogue (1.4)
The gym's published shop products (supplements, apparel, accessories) with live stock status. Only products the owner has explicitly published to the website appear β the POS-only inventory never leaks.
Query parameters (all optional):
200:
{
"products": [
{
"id": "β¦", "name": "Whey Protein 1kg",
"description": "β¦",
"category": { "id": "β¦", "name": "Supplements" },
"price": 2499, "mrp": 2999, "currency": "INR",
"unit": "Piece", "sku": "WP-1K", "imageUrl": "https://β¦",
"brand": "Optimum Nutrition", "size": "1kg",
"inStock": true,
"stockStatus": "in_stock",
"displayOrder": 0
}
],
"currency": "INR",
"locationId": "β¦", "locationName": "β¦"
}Field rules:
Ordering: the owner's display order, then name. Cost/margin fields are never emitted.
Errors: 400 invalid_location_id Β· 400 location_not_found Β· 400 invalid_category_id Β· 403 location_out_of_scope Β· 405 method_not_allowed Β· 500 internal_error β plus the shared auth codes (Β§3).
Try it:
curl -s "https://db.mygymdesk.in/functions/v1/website-products?in_stock_only=true" -H "x-mgd-api-key: $MGD_KEY"(Superseded in 1.5 β the shop checkout below is live.)
#5.10 POST /website-shop-order-create (1.5)
Creates a shop order and the Razorpay order to pay it. The customer picks up at a branch β there is no delivery.
Request:
{
"items": [ { "product_id": "β¦", "quantity": 2 } ],
"pickup_location_id": "β¦",
"customer": { "name": "Asha Menon", "phone": "9876543210", "email": "[email protected]" }
}- Up to 50 lines, quantity an integer 1β99. Only published products (Β§5.9) can be ordered.
pickup_location_idis required and must be one of the gym's active branches; a branch key may only sell its own branch.customeris required (name β₯ 2 chars; phone must normalize for the gym's country; email optional). The customer is matched to an existing member by phone, or a member is created.- Stock is checked per line at the pickup branch before anything happens β
409 insufficient_stockcarries{ product_id, available }. - The charged amount is computed server-side from the same all-in prices Β§5.9 displays. The client sends no price. Coupons are not yet supported.
200:
{
"order_id": "β¦", "order_number": "FZ-β¦",
"amount": 4998, "amount_in_paise": 499800, "currency": "INR",
"gateway": "razorpay", "razorpay_order_id": "order_β¦", "key_id": "rzp_β¦",
"test_mode": false, "collection_method": "own_pg",
"pickup_location_id": "β¦", "pickup_location_name": "β¦",
"lines": [ { "product_id": "β¦", "name": "β¦", "quantity": 2, "unit_price": 2499, "total": 4998 } ]
}Open Razorpay Checkout with key_id + razorpay_order_id + amount_in_paise, then finalize with Β§5.11. (PayPal gyms: capture client-side as in Β§6, skip the Razorpay fields, and finalize with the capture id.)
Errors: 400 invalid_json / invalid_items / invalid_quantity / invalid_phone / invalid_name / invalid_email / invalid_location_id Β· 403 location_out_of_scope Β· 404 location_not_found Β· 409 insufficient_stock Β· 422 product_unavailable / cart_empty / order_not_priced Β· 502 gateway_error / gateway_reconnect_required Β· 503 gateway_not_configured Β· 500 internal_error β plus the shared auth codes.
#5.11 POST /website-shop-order (1.5)
Verifies the capture and finalizes the order: marks it paid, writes the POS invoice, decrements stock at the pickup branch, and delivers the invoice to the customer over WhatsApp/email. Budget-exempt (Β§7).
Request: { "order_id": "β¦", "payment": { "gateway": "razorpay", "order_id": "order_β¦", "capture_id": "pay_β¦", "signature": "β¦" } } β PayPal: { "gateway": "paypal", "capture_id": "β¦" }, no signature.
200: { "ok": true, "order_id": "β¦", "order_number": "FZ-β¦", "invoice_id": "β¦", "invoice_number": "β¦", "status": "paid", "amount_charged": 4998, "currency": "INR", "oversold": false, "member_id": "β¦", "pickup_location_id": "β¦", "pickup_location_name": "β¦" }
oversold: stock can run out between create and pay. The order is still paid β no auto-refund β and the gym is alerted to resolve it; show the customer something like "Payment received β the gym will confirm availability." Don't treat oversold: true as a failure.
Replays are safe end-to-end: the same capture re-sent returns the same result (already: true), and a capture can never pay two different orders. A capture already used elsewhere β 409 duplicate_payment.
Errors: Β§5.10's set plus 400 invalid_order_id / order_mismatch / invalid_signature / payment_not_captured / amount_mismatch / currency_mismatch / payment_verification_failed Β· 404 order_not_found Β· 409 duplicate_payment Β· 410 order_not_payable.
The order lands in the gym's Product Orders fulfilment flow (paid β ready β collected) automatically.
#5.12 POST /website-membership-order (1.5)
Starts a membership purchase for any plan website-services?resource=plans publishes.
Request: { "plan_id": "β¦", "customer": { "name": "β¦", "phone": "β¦", "email": "β¦" }, "start_date": "2026-08-15", "location_id": "β¦" } β start_date (optional) must be today to +90 days; default today. location_id (optional) files the membership on a branch, same scope rules as everywhere (Β§9).
200:
{
"purchase_id": "β¦",
"order_id": "order_β¦", "amount": 499900, "currency": "INR",
"key_id": "rzp_β¦", "test_mode": false, "collection_method": "own_pg",
"plan_id": "β¦", "plan_name": "Annual", "duration_days": 365,
"location_id": "β¦", "location_name": "β¦"
}amount is in minor units and may exceed the plan price when the gym charges an online-payment fee β display it as the total. Keep purchase_id β it is what Β§5.13 takes. Coupons are not yet supported.
Errors: 400 invalid_json / invalid_plan_id / invalid_phone / invalid_name / invalid_email / invalid_start_date / invalid_location_id / location_not_found Β· 403 location_out_of_scope Β· 404 plan_not_found Β· 422 plan_not_priced Β· 502 / 503 gateway codes Β· 500 internal_error.
#5.13 POST /website-membership-purchase (1.5)
Verifies the capture and provisions everything: member (matched by phone or created), an active subscription, the invoice, the receipt over WhatsApp/email β and Member App access, delivered automatically via the welcome message with the setup link. Budget-exempt.
Request: { "order_id": "<purchase_id from Β§5.12>", "payment": { "gateway": "razorpay", "order_id": "order_β¦", "capture_id": "pay_β¦", "signature": "β¦" } } β purchase_id is accepted as an alias for the first field. PayPal: capture-first as usual.
200:
{
"ok": true, "member_id": "β¦", "subscription_id": "β¦",
"invoice_id": "β¦", "invoice_number": "β¦",
"status": "active", "plan_name": "Annual",
"start_date": "2026-08-15", "end_date": "2027-08-15",
"amount_charged": 4999, "currency": "INR",
"member_portal": { "provisioned": true },
"location_id": "β¦", "location_name": "β¦"
}Renewals (1.5, deliberate): if the phone already belongs to an active member, the purchase succeeds and creates a new subscription starting on start_date (default today) β it does not extend the existing one's end date, and there is no "already a member" error. A member renewing early should pass start_date = their current end date.
Replays are safe: the same capture re-sent returns the completed result (idempotent: true); a used capture β 409 duplicate_payment.
Errors: Β§5.12's set plus 400 invalid_order_id / order_mismatch / payment-verify codes Β· 404 order_not_found Β· 409 duplicate_payment Β· 500 internal_error.
#6. Taking a booking end-to-end
Two gateways. The gym is on one β Razorpay (Indian gyms) or PayPal (international). If you don't know which, call website-booking-order: a 503 gateway_not_configured means try PayPal (or the gym hasn't connected any gateway).
#Razorpay (order-first)
1. (server, cached) GET website-classes?resource=sessions β weekly grid
2. visitor picks a slot. Re-fetch sessions; take the CURRENT `id`.
3. (server) POST website-booking-order
{ session_id, booking_type:"class", customer }
β { order_id, amount (paise), currency, key_id, test_mode }
4. (browser) Razorpay Checkout with { key: key_id, order_id, amount }
β { razorpay_payment_id, razorpay_order_id, razorpay_signature }
5. (server) POST website-class-booking
{ session_id, customer,
payment:{ gateway:"razorpay",
order_id: razorpay_order_id,
capture_id: razorpay_payment_id,
signature: razorpay_signature } }
6. handle 409 slot_full / 409 duplicate_payment / 410 session_not_bookableThe server re-resolves the price, verifies the payment on the gym's Razorpay account, and books β you never pass an amount.
#PayPal (capture-first)
1β2. same as above.
3. (browser) PayPal Smart Buttons β capture client-side β capture id.
4. (server) POST website-class-booking
{ session_id, customer,
payment:{ gateway:"paypal", capture_id: <the capture id> } }The server fetches the capture on the gym's PayPal account, checks it's COMPLETED and matches the resolved price, and books. A refunded capture is rejected.
Both gateways: steps that call the API must run on your server β the payment secrets and the API key stay off the client. If a booking call fails after a successful capture (rare), the customer has paid with no booking β log these and reconcile from your gateway records.
#7. Rate limits
- Default: 30 requests/hour, shared across all endpoints, per key. Raise it under Configuration β Rate Limit (up to 1,000/hour self-serve), or ask MyGymDesk support. (1.4) The hard ceiling is 10,000/hour, enforced at the database β anything above 1,000 is a support request.
- Window is fixed (resets wholesale an hour after the first counted request), lazily triggered by the next request.
- Over the limit β
429. The429itself doesn't consume budget, and CORS preflights don't β but any request that authenticates and then fails validation still costs one credit. - Every
429body carriesretry_after_secondsβ the seconds until the window resets. Use it instead of guessing. There is noRetry-Afterheader; the value is in the JSON body. - (1.4/1.5) Every finalize endpoint is exempt β
website-class-booking,website-service-booking,website-shop-order,website-membership-purchaseneither check nor consume the budget, because a429there would arrive after the customer's payment was captured. They still authenticate and enforce the origin allow-list. The order-create calls are where the budget applies; throttle there, before any money moves.
This budget is small. A page showing plans + classes + timetable is 3 requests, and (1.5) a purchase costs ~3 budgeted requests too (catalogue read + order-create; the finalize is free) β at the default 30/hour a live retail site tops out around ten transactions an hour including every display read. Cache the display endpoints server-side (they change a few times a week), serve every visitor from your cache, and raise the limit before launch. Responses carry Cache-Control: no-store, so caching is on you.
#8. CORS, origins & calling from the browser
Browser calls now work from allow-listed origins. (Before v1.1 they were blocked at the edge.)
- A cross-origin
fetch()with thex-mgd-api-keyheader from an allow-listed origin succeeds. - The gym owner sets Allowed Origins to a comma-separated list of full origins including the scheme:
https://www.yourgym.com, https://yourgym.com.
#Allowed Origins is an exact-match security control
- Exact match (a trailing slash is tolerated:
https://yourgym.com/matcheshttps://yourgym.com). List each exact origin βyourgym.comandwww.yourgym.comare different origins. - A value without the scheme (
www.yourgym.com) never matches. (The on-screen placeholder is scheme-less β ignore it and includehttps://.) - A request from an origin not on the list gets
403 origin_not_allowed. A prefix likehttps://yourgym.com.attacker.netdoes not match. - Server-to-server calls have no
Originheader and always pass, allow-list or not. The list constrains browsers, not your backend. - Leaving Allowed Origins empty means the key is accepted from any origin (the key itself is the credential). Adding even one entry turns enforcement on for every endpoint, and every origin not on the list is then refused with
403 origin_not_allowed. So the list is opt-in β but once you opt in, it is complete: list every host the site is served from (https://yourgym.comandhttps://www.yourgym.com).
#Preflight (OPTIONS)
Browsers send a preflight before a cross-origin JSON call. Every endpoint answers it identically:
204 No Content, empty body.Access-Control-Allow-Originechoes your origin,Access-Control-Allow-Methods: GET, POST, OPTIONS,Access-Control-Allow-Headersincludesx-mgd-api-keyandcontent-type.- Preflight is not authenticated and costs no rate-limit credit. The key isn't known yet at that point, so the allow-list is enforced on the real request, not the preflight. A successful preflight therefore tells you nothing about whether your key or origin is valid.
Access-Control-Allow-Methods advertises GET, POST, OPTIONS on every endpoint for consistency; the endpoint still enforces its own verb and answers 405 method_not_allowed otherwise.
Through 1.2,
capture-website-leadalone answered preflight with200and a wildcard origin instead of204and your echoed origin. Browsers never saw it, because the Cloudflare layer in front of the documented base URL normalises preflight β it only showed up when calling the underlying*.supabase.cohost directly, which you should not do. Normalised in 1.3 so the code and the edge agree.
#Still: prefer a server-side proxy
Browser calls put the API key in client code. Payments can't be forged (the server verifies every capture), so a leaked key can't mint free bookings β but it can create leads and burn the gym's rate limit. If you have any backend, proxy through it and keep the key private. Use direct browser calls for genuinely static sites, with a low rate limit as a cap.
All seven endpoints now follow this same policy β capture-website-lead used to log-and-allow a bad origin and no longer does, so if the gym has configured an allow-list, make sure the site's real origin is on it. A plain <form action="β¦"> POST doesn't work anywhere β the API needs a JSON body.
#9. Multi-location (branches)
The single rule: the branch is a property of your API key, not of your request. You cannot pass a branch in a payload, and no request can reach a branch the key isn't entitled to. There are two kinds of key.
#Branch key β "Assign Leads to Location" is set
The key belongs to one branch, and everything it does is confined there:
This is what you want for a gym running one website per branch: hand each site its own key and no site can read or book another branch's schedule.
#Tenant-wide key β no location set
The key spans the whole gym: reads return every branch, any branch's session can be priced and booked, and captured leads fall back to the gym's primary branch (then any active branch). Use this for a single website that presents all branches together.
#Tenant-wide rows
Some things belong to the gym rather than to a branch, and a branch key still sees them:
A branch key cannot act on a session it cannot see. Session reads are strictly scoped, so a session with no branch (
location_id: null) never appears in a branch key's timetable β and, since 1.3, that same key is refused with403 session_out_of_scopeif it tries to price, order or book one by id. Through 1.2 the guard was looser than the read filter: such a session was invisible on the timetable yet transactable by anyone who had the id. Tenant-wide keys are unfiltered on reads and correspondingly unrestricted here. In short: if it isn't in your timetable, it isn't yours to book.
#?location_id= β a sub-filter, never a way out
Display endpoints accept an optional ?location_id=<uuid>. It narrows within your key's scope and can never widen it:
curl "https://db.mygymdesk.in/functions/v1/website-classes?resource=sessions&location_id=<branch-uuid>" -H "x-mgd-api-key: $MGD_KEY"Get branch UUIDs from the locationId values in an unfiltered response.
Filtering sessions is strict.
?location_id=on a sessions resource returns that branch's sessions only β a tenant-wide session with no branch is excluded. On catalog and plans the filter keeps tenant-wide rows, because a service offered everywhere is genuinely available at that branch.
#Where the branch shows up in responses
- Display rows use camelCase:
locationId/locationName. - Booking success bodies use snake_case:
location_id/location_name. website-session-pricereturnslocation_id.locationNameisnullwhen the row is tenant-wide.
#10. Recipes
#10.1 Cached membership pricing (server-side)
const API = "https://db.mygymdesk.in/functions/v1";
const KEY = process.env.MGD_API_KEY; // the mgd_live_β¦ key, from the environment
let cache = { plans: [], at: 0 };
async function getPlans() {
if (Date.now() - cache.at < 30 * 60 * 1000) return cache.plans; // protect the quota
const res = await fetch(`${API}/website-services?resource=plans`, {
headers: { "x-mgd-api-key": KEY }
});
if (!res.ok) return cache.plans; // serve stale rather than an empty page
const { plans } = await res.json();
cache = { plans, at: Date.now() };
return plans;
}Render name, price, currency, intervalLabel, features; highlight featured.
#10.2 Lead form β server-side proxy (recommended)
app.post("/api/enquiry", async (req, res) => {
const { name, phone, email, message, website } = req.body;
if (website) return res.json({ ok: true }); // honeypot: drop bots
if (!name || !phone) return res.status(400).json({ error: "Name and phone required" });
const r = await fetch(`${API}/capture-website-lead`, {
method: "POST",
headers: { "x-mgd-api-key": KEY, "content-type": "application/json" },
body: JSON.stringify({
name: String(name), phone: String(phone),
email: email ? String(email) : undefined,
notes: message ? String(message) : undefined,
source: "website",
source_details: req.get("referer") ?? "https://yourgym.com/contact"
})
});
if (r.status === 429) return res.status(503).json({ error: "Please try again shortly." });
if (!r.ok) return res.status(502).json({ error: "Could not submit. Please call us." });
return res.json({ ok: true }); // 200 and 201 are both success
});There's no built-in spam protection β add your own honeypot/CAPTCHA.
#10.3 Browser lead capture (static site, no backend)
capture-website-lead works from any origin. This puts the key in public JS β use a low rate limit and rotate the key if you see junk leads:
await fetch("https://db.mygymdesk.in/functions/v1/capture-website-lead", {
method: "POST",
headers: { "x-mgd-api-key": MGD_KEY, "content-type": "application/json" }, // injected at build time
body: JSON.stringify({ name, phone, email })
});For the display endpoints from a static site, add the site's origin to Allowed Origins and call with the x-mgd-api-key header.
#10.4 Class booking (Razorpay), end to end
See Β§6. All API calls (website-booking-order, website-class-booking) run server-side; only Razorpay Checkout runs in the browser. Handle 409 slot_full (sold out β refund), 409 duplicate_payment (capture already used), 410 session_not_bookable (cancelled).
#10.4a Shop checkout (Razorpay), end to end (1.5)
1. (server, cached) GET website-products β catalogue
2. visitor builds a cart; your server re-sends product_ids + quantities:
3. (server) POST website-shop-order-create
{ items, pickup_location_id, customer }
β { order_id, razorpay_order_id, amount_in_paise, key_id }
handle 409 insufficient_stock ({product_id, available})
BEFORE opening Checkout β adjust the cart and retry.
4. (browser) Razorpay Checkout
5. (server) POST website-shop-order
{ order_id, payment:{ gateway:"razorpay",
order_id, capture_id, signature } }
6. show "paid β collect at <pickup_location_name>". If oversold:true, show
"payment received β the gym will confirm availability" (NOT a failure).#10.4b Membership sale (Razorpay), end to end (1.5)
1. (server, cached) GET website-services?resource=plans β pricing cards
2. (server) POST website-membership-order
{ plan_id, customer, start_date?, location_id? }
β { purchase_id, order_id, amount, key_id } KEEP purchase_id
3. (browser) Razorpay Checkout with { key_id, order_id, amount }
4. (server) POST website-membership-purchase
{ order_id: purchase_id,
payment:{ gateway:"razorpay", order_id, capture_id, signature } }
β { member_id, subscription_id, start_date, end_date }
5. The member gets the receipt + a WhatsApp welcome with their Member App
setup link automatically β nothing for your site to send.
Renewal = the same flow with the same phone; pass start_date to stack after
the current membership. There is no "already a member" error.#10.5 WordPress
Two drop-in snippets for a WordPress theme's functions.php (or a small site-plugin). The key lives in wp-config.php as a constant, so it never reaches the browser:
// wp-config.php β above "That's all, stop editing".
define( 'MGD_API_KEY', getenv( 'MGD_API_KEY' ) ); // or paste it here β wp-config.php is never servedLead form β capture-website-lead (key stays server-side). A front-end form posts to admin-post.php; PHP attaches the key and calls the API. The admin_post_nopriv_ hook is what makes it work for logged-out visitors β register both:
// functions.php
add_action( 'admin_post_nopriv_mgd_lead', 'mgd_handle_lead' ); // logged-out visitors
add_action( 'admin_post_mgd_lead', 'mgd_handle_lead' ); // logged-in too
function mgd_handle_lead() {
$back = wp_get_referer() ?: home_url();
// Honeypot: humans leave 'company' empty; bots fill every field.
if ( ! empty( $_POST['company'] ) ) { wp_safe_redirect( $back . '?lead=ok' ); exit; }
$name = sanitize_text_field( wp_unslash( $_POST['name'] ?? '' ) );
$phone = sanitize_text_field( wp_unslash( $_POST['phone'] ?? '' ) );
if ( '' === $name || '' === $phone ) { wp_safe_redirect( $back . '?lead=err' ); exit; }
$res = wp_remote_post( 'https://db.mygymdesk.in/functions/v1/capture-website-lead', array(
'timeout' => 15,
'headers' => array(
'x-mgd-api-key' => MGD_API_KEY, // from wp-config.php β never printed
'content-type' => 'application/json',
),
'body' => wp_json_encode( array(
'name' => $name,
'phone' => $phone,
'email' => sanitize_email( wp_unslash( $_POST['email'] ?? '' ) ),
'notes' => sanitize_textarea_field( wp_unslash( $_POST['message'] ?? '' ) ),
'source' => 'website',
'source_details' => $back,
) ),
) );
// capture-website-lead returns 200 or 201 on success.
$code = is_wp_error( $res ) ? 0 : (int) wp_remote_retrieve_response_code( $res );
wp_safe_redirect( $back . ( in_array( $code, array( 200, 201 ), true ) ? '?lead=ok' : '?lead=err' ) );
exit;
}The matching markup β the honeypot field is visually hidden, never by type="hidden" (bots skip those):
<form action="/wp-admin/admin-post.php" method="post">
<input type="hidden" name="action" value="mgd_lead">
<input type="text" name="name" placeholder="Your name" required>
<input type="tel" name="phone" placeholder="Phone" required>
<input type="email" name="email" placeholder="Email">
<textarea name="message" placeholder="Message"></textarea>
<input type="text" name="company" tabindex="-1" autocomplete="off"
aria-hidden="true" style="position:absolute;left:-9999px">
<button type="submit">Enquire</button>
</form>Pricing shortcode, cached 30 minutes β this is the rate-limit answer for WordPress. get_transient/set_transient mean the API is called once per half hour, not once per visitor, so 10,000 page views cost ~48 calls a day and never approach the quota:
// functions.php β use [mgd_pricing] in any page or post.
add_shortcode( 'mgd_pricing', 'mgd_pricing_shortcode' );
function mgd_pricing_shortcode() {
$plans = get_transient( 'mgd_plans' );
if ( false === $plans ) { // cache miss β one network call
$res = wp_remote_get(
'https://db.mygymdesk.in/functions/v1/website-services?resource=plans',
array( 'timeout' => 15, 'headers' => array( 'x-mgd-api-key' => MGD_API_KEY ) )
);
if ( is_wp_error( $res ) || 200 !== (int) wp_remote_retrieve_response_code( $res ) ) {
return '<p>Pricing is loading β please check back shortly.</p>'; // don't cache failures
}
$body = json_decode( wp_remote_retrieve_body( $res ), true );
$plans = $body['plans'] ?? array();
set_transient( 'mgd_plans', $plans, 30 * MINUTE_IN_SECONDS ); // the quota-saver
}
$out = '<div class="mgd-plans">';
foreach ( $plans as $p ) {
$out .= sprintf(
'<div class="mgd-plan%s"><h3>%s</h3><p class="mgd-price">%s %s<span>/%s</span></p></div>',
empty( $p['featured'] ) ? '' : ' is-featured',
esc_html( $p['name'] ),
esc_html( $p['currency'] ),
esc_html( $p['price'] ),
esc_html( $p['intervalLabel'] )
);
}
return $out . '</div>';
}Contact Form 7 / Elementor Forms can't do this on their own β neither can attach the
x-mgd-api-keyrequest header to an outbound call, so they can't reach the API directly. Point their submit action at theadmin-post.phphandler above (or itswp_ajax_nopriv_mgd_leadtwin for an AJAX submit) and let PHP add the key.
#11. What happens after a call
#A lead (capture-website-lead)
- Appears immediately in Leads β Enquiries, status New, filed against the key's branch β or, for a tenant-wide key, the gym's primary branch.
- Left unassigned. A repeat phone updates the existing lead (appends a re-enquiry note). If the phone already belongs to a member, the lead is auto-marked converted.
- No email/WhatsApp/in-app notification is sent for API leads today β the gym watches the Enquiries list. (Tell the owner this β leads from the hosted form do notify; this endpoint does not.)
#A booking
- The booking appears in the gym's class/service schedule and attendance screens, at the session's branch.
- The customer is matched by phone to an existing member, or a new active member is created (with a converted lead). A one-off booking therefore enrols a member β make sure the owner expects that.
- The payment is recorded and shows in the gym's revenue reports.
- No automatic confirmation is sent to the customer β send your own.
#Webhooks
There are no outbound webhooks β MyGymDesk won't call your server on changes. Poll if you need to (mind the rate limit).
#12. Troubleshooting
401 on every request β key incomplete (41 chars, mgd_live_ prefix) or regenerated (old key dies instantly).
403 key_inactive β the toggle at Settings β Integrations β Website Lead Capture is off (the default for a new key).
403 origin_not_allowed β the calling origin isn't in Allowed Origins, or the stored value has no scheme. Use the exact full origin: https://www.yourgym.com. Or move the call server-side.
Browser console "blocked by CORS" β the gym has configured an Allowed Origins list and the calling origin isn't on it. Add the exact origin (with scheme), clear the list to go unrestricted, or proxy server-side. An empty list is not the cause β empty means unrestricted.
400 invalid_location_id / location_not_found β ?location_id= must be a branch UUID from an unfiltered response.
403 location_out_of_scope / 403 session_out_of_scope β the key is scoped to one branch and you asked for something outside it: a different branch, or (since 1.3) a session that has no branch at all. A branchless session never appears in a branch key's timetable, so if you're hitting this with an id that isn't in any list you fetched, that's why. Use that branch's own key, or ask the gym for a tenant-wide key. See Β§9.
422 member_pricing_unsupported β you sent is_member truthy with booking_type=service. There is no member rate on this API; drop the field and you'll get the gym's standard rate. See Β§5.6.
Booking returns 400 payment_verification_failed β the capture couldn't be verified on the gym's gateway account (wrong id, wrong gateway, or the gym's gateway isn't connected). No booking was created.
Booking returns 400 amount_mismatch β the captured amount doesn't match the session's server-resolved price. For Razorpay, always create the order via website-booking-order and pay exactly its amount.
Booking returns 409 duplicate_payment β that capture id was already used. Each capture books once.
409 already_booked β class only; this customer already has a booking on this session (see limitations).
500 from capture-website-lead β almost always a non-string field ("phone": 9876543210). Cast every field to a string.
429 and you don't know how long to wait β read retry_after_seconds from the response body. There is no Retry-After header.
Lead 200 with "action":"updated" and no new row β working as designed; that phone already exists. Look up by phone, not date.
401 vs 403 vs 429: 401 = key not recognised Β· 403 = key off or origin not allowed Β· 429 = quota gone.
#13. Current limitations
#14. Versioning & compatibility
This is v1. There is no version in the URL and no version header β the endpoints above are the contract.
What we commit to:
- Additive changes ship without notice. New fields may appear in responses and new optional parameters may be accepted. Parse defensively: ignore keys you don't recognise, and don't assert on the exact set of fields.
- Error
errorcodes are stable;messagewording may be reworded for clarity. Always branch onerror. - Breaking changes β removing or renaming a field, changing a status code, tightening validation β are announced to gym owners with an API key before they ship, via the dashboard and the email on the account, and recorded in the Changelog.
What we don't promise: a fixed notice period, a deprecation window with both versions running in parallel, or a sandbox environment. This is a small, young API; if you're building something that depends on it, tell MyGymDesk support so you're on the list that gets contacted first.
#Changelog
#1.6 β 2026-08-08 (enforced from 2026-08-15)
Plan entitlement is now checked on every request β the re-check that footnote ΒΉ has warned about since 1.1.
- Leads endpoint (
capture-website-lead): Pro plan or above. Unchanged in practice for every existing key. - All other endpoints: Enterprise plan or the Full Website API add-on (βΉ500/month Β· βΉ5,000/year Β· βΉ12,000/3-year, purchasable by Pro gyms under Settings β Billing & Plan β Add-ons).
- New auth failure:
403 plan_upgrade_required(Β§3). It never consumes rate-limit budget. Branch on the code β the message differs by endpoint class. - Payments are never stranded: the four budget-exempt finalize endpoints complete a verified payment even if the gym's entitlement lapsed between order-create and finalize (order-create is where a lapse blocks).
- Suspended gyms, and gyms more than 7 days past plan expiry, now fail every endpoint. The add-on has the same 7-day grace past its end date.
- Doc fixes: the settings tab is labelled Website Integration (previously documented as "Website Lead Capture"); Β§3 said "all seven endpoints" β there have been twelve since 1.5.
#1.5 β 2026-08-08
Four new endpoints β the API now takes money for shop products and memberships. Endpoint inventory 8 β 12.
- Shop checkout (Β§5.10/Β§5.11):
website-shop-order-create(server-priced order + per-line stock check at the pickup branch, published products only) βwebsite-shop-order(verify + finalize: paid order, POS invoice, stock decrement, receipt over WhatsApp/email). Orders land in the gym's Product Orders fulfilment flow.oversold: trueis a paid-but-stock-short outcome, not a failure β the gym is alerted and resolves it. - Membership purchase (Β§5.12/Β§5.13):
website-membership-order(published plans only; optionalstart_datetodayβ+90d; optional branch) βwebsite-membership-purchase(verify + provision: member matched-by-phone-or-created, active subscription, invoice, receipt, Member App access via the automatic welcome message). Renewal semantics (deliberate): an existing active member purchasing again gets a new subscription fromstart_date/today β no stacking onto the old end date, no "already a member" error. - Both finalize endpoints are budget-exempt like the booking finalizes (Β§7); the create calls stay budgeted. A purchase costs ~3 budgeted requests.
- Owner notifications: the gym now gets a WhatsApp + push alert for website leads, paid bookings, shop orders and membership purchases β throttled to one per event type per 15 minutes, so a burst can't flood the owner's phone. Service bookings additionally send the customer a confirmation (previously only class bookings did).
- Not yet: coupon codes on any purchase endpoint; a "pickup ready" customer notice for shop orders (needs a new WhatsApp template β planned).
- Amounts charged may exceed the plan/cart base when the gym configures an online-payment fee; the order-create response's
amountis always the charged total.
#1.4 β 2026-08-08
New endpoint:
GET /website-products(Β§5.9) β the gym's published shop catalogue. Owner-published products only;priceis the all-in charged amount;mrpomitted unless aboveprice; newbrand/sizedisplay fields; stock is a tri-statestockStatus(in_stock/low_stock/out_of_stock) with per-branch statuses for tenant-wide keys β exact quantities are deliberately never exposed. Filters:category_id,brand,in_stock_only. Endpoint inventory grows 7 β 8.
Behaviour changes:
capture-website-leadaccepts an optionallocation_id(Β§5.1) so a multi-branch site can file each enquiry on the right branch. Same scope rules as every read sub-filter: a tenant-wide key may name any of the gym's branches, a branch key only its own (403 location_out_of_scope); malformed β400 invalid_location_id; another gym's branch β400 location_not_found. Absent, routing is byte-identical to 1.3. The success body now also confirmslocation_id/location_name.- The booking-finalize endpoints are exempt from the hourly budget (Β§7). A
429onwebsite-class-booking/website-service-bookinglanded after the customer's capture existed at the gateway β money taken, booking bounced by a quota. Finalize calls now skip the quota check entirely (still authenticated, still origin-checked). The order-create call remains budgeted. - The rate-limit bound is now real at the database β
rate_limit_per_houris constrained to 1β10,000. The self-serve settings screen keeps its 1,000 cap; above that is a support request.
#1.3 β 2026-08-06
Three behaviour changes and a set of corrections, all shipped before this guide was published. Every claim below was verified against the deployed functions, not against the previous version of this document.
Behaviour changes:
- Member pricing is gone from this API, and asking for it is now an error.
is_membertruthy withbooking_type=servicereturns422 member_pricing_unsupportedon bothwebsite-session-priceandwebsite-booking-order. This closes a money bug: the order endpoint minted the Razorpay order at the member rate while the booking endpoint charges the non-member rate on purpose (it cannot verify a membership claim), and the amount check is exact β so the customer paid, and the booking was then refused withamount_mismatch. Every quote and charge is now the non-member rate, resolved from one place. A member's own rate applies in the member portal, where they are signed in. Related: the old "ifprice_memberis 0, silently charge the non-member price" fallback is gone βservices.price_memberis no longer read by this API at all.is_memberremains an accepted no-op on classes, which have a single fee. - A branch key can no longer book what it cannot see. A session with no branch never appeared in a branch key's timetable, but the guard used to let that key price, order and book it by id. It is now
403 session_out_of_scope, matching the read filter exactly. Tenant-wide keys are unaffected. No live session was affected by this tightening. website-booking-ordernever returns200withkey_id: null. The Checkout key is now resolved before the order is minted, so a gym whose rail can't produce one gets503 gateway_not_configuredand no orphan order at Razorpay. Previously the key was fetched after the order and could come back null, yielding a200Checkout couldn't open.- Everything that can reject a booking now runs before the payment is verified. The
customer.phoneE.164 check lived inside member resolution, which runs after verification β so an unparseable phone meant the capture was confirmed and the booking was then refused with400 invalid_phone: money taken, no booking. Both booking endpoints had this, identically. The phone check and the duplicate-capture replay guard now run up front, so a rejectable request never costs a capture (and a replayed capture no longer calls the gateway at all). The rejections that remain after verification are only those the database decides atomically at write time β capacity (409 slot_full), double-booking (409 already_booked) and410 session_not_bookable; those cannot be moved earlier without lying about a race. - The 90-day timetable window is computed in the gym's own calendar, not UTC.
session_dateis a local date, so a UTC bound was wrong for part of every day β a New York gym lost its own remaining evening sessions once UTC rolled over, and a Guam gym advertised one that had already passed. ?location_id=onwebsite-classes?resource=catalogis accepted when it is in scope, instead of being refused outright. Out-of-scope still403 location_out_of_scope. Class types are tenant-wide so the result set is unchanged β this only stops a well-formed request being rejected for no reason.400 location_filter_not_applicableis retired and can no longer be returned.
Smaller fixes:
customer.name/customer.emailonwebsite-booking-orderare now actually used β stamped onto the gateway order notes for the gym's reconciliation. They were accepted and discarded while this guide claimed they prefilled Checkout (they never could; prefill is your own client-side call).is_membertruthiness is now identical on every endpoint (true/1/yes).website-session-priceaccepted all three whilewebsite-booking-orderaccepted onlytrue, sois_member=1was honoured by the quote and ignored by the order.- Preflight (
OPTIONS) is now204+ echoed origin on all seven endpoints;capture-website-leadalone answered200+ wildcard. See Β§8.
Corrections to this guide (no API change):
- Retracted: "a free (zero-priced) session books without creating a payment row." It cannot β
422 session_not_pricedis returned first. See Β§5.8. - Resolved a contradiction: 1.2 said sessions "always belong to one branch" in Β§9 while Β§5.6 said
location_idcould benullfor a tenant-wide session. Branchless sessions do exist; the rule is now stated once, with the scoping consequence. - Documented:
customer.phoneon the booking endpoints must normalize to E.164 for the gym's country β a long-standing hard gate that returns400 invalid_phone, and stricter than lead capture's 8β15 digits. - Each booking endpoint's own error table now lists
401,403 key_inactive,403 origin_not_allowed,403 session_out_of_scopeand429, instead of leaving them to the blanket section in Β§3. - Documented:
intervalLabelcan be the empty string when a plan has no duration set β render the price alone rather than an empty suffix. - Documented:
sport(classes) is""when unset whilecategory(services) isnull. The asymmetry is real; handle both as "no category". - Documented:
currencyonresource=planscan be overridden per row, so two rows in one response may differ. Read it per row. - Made explicit that the same
errorcode carries differentmessagetext on different endpoints β branch onerror, never onmessage.
#1.2 β 2026-08-06
- Your key now defines its branch, for reads as well as writes. A key with a location set sees only that branch's sessions and services, can only price and book that branch's sessions (
403 session_out_of_scope), and files leads there. A key with no location spans every branch. Previously the key's branch applied to lead capture only, and any key could read or book any branch. See Β§9. ?location_id=is now a sub-filter inside the key's scope. Asking for another branch returns403 location_out_of_scopeinstead of returning that branch's data. Onwebsite-classes?resource=catalogit returned400 location_filter_not_applicableinstead of being silently ignored. (That code was retired in 1.3 β an in-scope branch is now simply accepted.)- One error shape everywhere:
{"error":"snake_case_code","message":"β¦"}. Codes that previously returned nomessagenow carry one, andunknown resourcewas renamedunknown_resource. retry_after_secondson every429(previously only on lead capture).- Allowed Origins: empty now means unrestricted. Previously an empty list fell back to an internal default allow-list that blocked most callers. Adding any entry switches on enforcement for every endpoint β including
capture-website-lead, which used to log a bad origin and allow it through. website-session-pricereturnslocation_id.- Corrections to this guide (the API did not change): the phone rule on lead capture is 8β15 digits (an older error message said 10β15);
website-session-pricealso returns405and500; the timetable's real bound is 5,000 dated sessions per 90-day scan, not thespotsBookedunder-count previously described.β this was wrong and is retracted in 1.3. A zero-priced session is refused withpayment_idin a booking response is nullable for free sessions422 session_not_pricedand can never be booked, so the case does not exist.
#1.1 β 2026-07-20
- Payment verification: bookings now verify the capture against the gym's gateway and resolve the amount from the gym's own data. Forged captures,
amount: 0, and amount tampering are rejected before anything is created. payment.amount/payment.currencydropped from the booking contract β the server charges its own resolved amount in the gym's currency. Omit them; if an older integration still sends them, they're accepted and ignored.- Razorpay added as a first-class gateway via the new order-first flow (
website-booking-orderβ Checkout β booking). - Capture-id idempotency: replaying a capture returns
409 duplicate_paymentand creates nothing. - Multi-location:
locationId/locationNameon all display and booking responses, plus an optional?location_id=filter. - Plan billing periods fixed: quarterly/half-yearly plans no longer render as "per year"; a
durationDaysfield was added. - Browser calls now work from allow-listed origins;
allowed_originsis now an exact-match security control (full origin, with scheme).
#1.0 β 2026-07-20 (internal draft, superseded)
- First documentation of the endpoints. Not published externally.
Support: contact MyGymDesk support through the owner dashboard, quoting the endpoint, HTTP status, and time. Never include the API key in a support message.