MyGymDesk Website API — Integration Guide
Audience: the web developer building or maintaining a MyGymDesk customer's website. Version: 1.1 (2026-07-20) · 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.1: payments are now verified server-side (forged or replayed captures are rejected and amounts are resolved from the gym's own data), Razorpay is a first-class gateway alongside PayPal, responses carry branch (location) information with an optional filter, and browser calls now work from allow-listed origins. See the Changelog.
#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
- Changelog
#1. What you can build
#Endpoint inventory
All endpoints live under https://db.mygymdesk.in/functions/v1/.
¹ The Website Lead Capture settings screen where the key is generated requires the Pro plan. The API itself doesn't re-check the plan today, so an existing key keeps working after a downgrade — but the plan may be re-checked in a future version, so don't rely on that.
² 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.
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 Lead Capture tab.
- A key is created automatically the first time the page opens. It looks like:
(code
mgd_live_a7f3k9m2p8q1w5e6r4t7y0u3i8o2p5a9mgd_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. The settings screen creates a key only when none exists and offers no "add key" and no delete. There is no per-branch key. Multi-location gyms: point API leads at one triage branch and re-assign in the dashboard, or ask MyGymDesk support about additional keys.
#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 grants write access to the gym's CRM and booking system. Treat it like a password:
- Store it in an environment variable or secret manager, never in a public repo.
- 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: mgd_live_xxxx?key=mgd_live_xxxxNo other credential is needed — no Authorization, no Supabase apikey. The key alone identifies the gym.
#Authentication failures
Two variations: the booking endpoints append a fixed message: "Unauthorized" to these (ignore it, read error); capture-website-lead predates the shared layer and uses different wording ({"error":"Missing or invalid API key"} etc.) and never blocks on origin. Match on the HTTP status, not the message string.
#4. Quick start (2 minutes)
Send a test lead (replace mgd_live_xxxx):
curl -i -X POST "https://db.mygymdesk.in/functions/v1/capture-website-lead" \
-H "x-mgd-api-key: mgd_live_xxxx" \
-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_live_xxxx"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):
#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: 201 {success, message, lead_id, action:"created"} for a new phone, or 200 with "action":"updated" for a re-enquiry.
Errors: 400 (invalid JSON / name / phone / email), 401/403 (auth), 405, 413 (Content-Length > 5 KB), 429, 500.
De-duplication: matched on the last 10 digits of phone, per gym. +91 98765 43210, 09876543210, 9876543210 are the same person.
#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.
#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.
#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. - Class types are tenant-wide →
locationIdis always null. A specific session carries a branch (below).
#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).- 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.
#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.
{ "session_id":"…", "booking_type":"class", "amount":500, "currency":"INR", "valid":true }
valid:falsecomes back with HTTP200— it means the session exists but has no price. Branch onvalid, not the status code.
Errors: 400 invalid_session_id, 404 session_not_found, 422 invalid_booking_type, 500.
#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:
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"
}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).collection_methodisown_pgoroauth— informational.
Errors: 400 invalid_json / 400 invalid_session_id, 404 session_not_found (unknown/foreign session), 405 method_not_allowed, 422 invalid_booking_type, 422 session_not_priced (no price set), 503 gateway_not_configured (gym hasn't connected Razorpay), 502 gateway_reconnect_required (their Razorpay connection needs attention), 502 gateway_error, 500 internal_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.location_id/location_name= the session's branch.lead_idis nullable — populated only when the booking created a brand-new member; for a returning member it'snull.booking_idandmember_idare always present.
Errors (all {"error":"<code>","message":"…"} — branch on 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, or ask MyGymDesk support.
- 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. - No
Retry-Afterheader (except aretry_after_secondsbody field oncapture-website-lead). Back off ~60 minutes.
This budget is small. A page showing plans + classes + timetable is 3 requests. 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_allowedwith noAccess-Control-Allow-Origin, so the browser blocks it. A prefix likehttps://yourgym.com.attacker.netdoes not match. - Leaving Allowed Origins empty does not mean "any website" for the display and booking endpoints — leave it empty only for server-side use.
#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.
capture-website-lead is the exception: it returns Access-Control-Allow-Origin: * and is callable from any origin (it's lead capture only). A plain <form action="…"> POST doesn't work anywhere — the API needs a JSON body.
#9. Multi-location (branches)
Multi-location gyms run more than one branch. Every display row and both booking success bodies carry the branch's UUID and display name (or null for a tenant-wide row):
- Display rows use camelCase:
locationId/locationName. - Booking success bodies use snake_case:
location_id/location_name(see §5.8).
When it's null: class types (?resource=catalog on classes) and membership plans are tenant-wide — they apply at every branch, so they always report null. Class and service sessions and services carry a real branch. Service packages may be branch-scoped or tenant-wide — a tenant-wide package (no branch) also reports null, exactly like a membership plan.
#Filtering to one branch
Display endpoints accept an optional ?location_id=<uuid>:
curl "https://db.mygymdesk.in/functions/v1/website-classes?resource=sessions&location_id=<branch-uuid>" \
-H "x-mgd-api-key: mgd_live_xxxx"- Absent → all branches.
- A UUID that isn't one of the gym's branches →
400 location_not_found. Not a UUID →400 invalid_location_id. - On
resource=plans, a branch filter keeps all (tenant-wide) membership plans and returns that branch's packages plus tenant-wide packages.
Get the branch UUIDs from the locationId values in an unfiltered response.
Leads always file against the branch configured on the key (Settings → Assign Leads to Location) — there's one key per gym, so lead capture can't target a branch per request.
#10. Recipes
#10.1 Cached membership pricing (server-side)
const API = "https://db.mygymdesk.in/functions/v1";
const KEY = process.env.MGD_API_KEY; // mgd_live_xxxx
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_live_xxxx", "content-type": "application/json" },
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.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', 'mgd_live_xxxx' );Lead 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 configured 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 origin isn't allow-listed, or you're calling a display/booking endpoint from an empty-allow-list key. Add the exact origin (with scheme), or proxy server-side.
400 invalid_location_id / location_not_found — ?location_id= must be a branch UUID from an unfiltered response.
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.
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
#Changelog
#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.