We use cookies to improve your experience.

    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 gets 403 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, every 429 tells you how long to wait, and leaving Allowed Origins empty means unrestricted rather than blocked.


    #Contents

    1. What you can build
    2. Getting your API key
    3. Authentication & base URL
    4. Quick start (2 minutes)
    5. Endpoint reference
    6. Taking a booking end-to-end
    7. Rate limits
    8. CORS, origins & calling from the browser
    9. Multi-location (branches)
    10. Recipes
    11. What happens after a call
    12. Troubleshooting
    13. Current limitations
    14. Versioning & compatibility
    15. Changelog

    #1. What you can build

    You want to…Use
    Put an "Enquire now" / "Book a trial" form on the sitecapture-website-lead
    Show membership & package pricing cardswebsite-services?resource=plans
    Show a class timetablewebsite-classes?resource=sessions
    List the class types offeredwebsite-classes?resource=catalog
    Show bookable services (physio, sauna, court hire…)website-services?resource=catalog / ?resource=sessions
    Take a class booking with paymentwebsite-booking-order β†’ website-class-booking
    Take a service booking with paymentwebsite-booking-order β†’ website-service-booking
    Confirm the true price of a sessionwebsite-session-price
    Show the gym's shop / supplements cataloguewebsite-products
    Sell shop products with payment + pickupwebsite-shop-order-create β†’ website-shop-order
    Sell a membership with payment + Member App accesswebsite-membership-order β†’ website-membership-purchase

    #Endpoint inventory

    All endpoints live under https://db.mygymdesk.in/functions/v1/.

    EndpointMethodPurposePlanΒΉRate limit
    capture-website-leadPOSTCreate/update a lead in the CRMPro+shared 30/hrΒ²
    website-classesGETClass catalog + weekly timetableEnterprise / add-onshared
    website-servicesGETServices, service sessions, membership plansEnterprise / add-onshared
    website-productsGETPublished shop products with live stock status (1.4)Enterprise / add-onshared
    website-session-priceGETAuthoritative price for one sessionEnterprise / add-onshared
    website-booking-orderPOSTCreate a Razorpay order for a bookingEnterprise / add-onshared
    website-class-bookingPOSTBook a class session (after payment)Enterprise / add-onexemptΒ³
    website-service-bookingPOSTBook a service session (after payment)Enterprise / add-onexemptΒ³
    website-shop-order-createPOSTCreate a shop order + Razorpay order (1.5)Enterprise / add-onshared
    website-shop-orderPOSTFinalize a shop order (after payment) (1.5)Enterprise / add-onexemptΒ³
    website-membership-orderPOSTStart a membership purchase (1.5)Enterprise / add-onshared
    website-membership-purchasePOSTComplete a membership purchase (after payment) (1.5)Enterprise / add-onexemptΒ³

    ΒΉ (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:

    1. Settings β†’ Growth & Apps β†’ Integrations β†’ Website Integration tab (labelled "Website Integration" in the app; older versions of this guide called it "Website Lead Capture").
    2. 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.)
    3. Turn the toggle ON. New keys start inactive β€” until the switch is on, every request returns 403 and the configuration fields stay hidden.
    4. 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)

    FieldEffect
    Assign Leads to LocationThe branch this key works on β€” for everything, not just leads. Set it and the key sees only that branch's timetable and services, can only price and book that branch's sessions, and files leads there. Leave it unset for a key that spans every branch. It can never be overridden per request (see Β§9).
    Rate Limit (leads/hour)The shared hourly budget for all endpoints (the label says "leads/hour"; it governs every endpoint). Default 30.
    Allowed OriginsComma-separated full origins, including the scheme: https://www.yourgym.com, https://yourgym.com. Empty = the key works from anywhere; adding even one entry switches enforcement on and refuses every other origin. See Β§8.

    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-key header over the ?key= query parameter. Query strings land in logs and Referer headers.

    #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):

    http
    x-mgd-api-key: <your key>
    code
    ?key=<your key>

    No other credential is needed β€” no Authorization, no Supabase apikey. The key alone identifies the gym.

    #Authentication failures

    StatusBodyMeaning
    401{"error":"unauthorized","message":"Missing or invalid API key"}Key missing, malformed, or unknown
    403{"error":"key_inactive","message":"This API key has been deactivated"}Key exists but the toggle is off
    403{"error":"origin_not_allowed","message":"This origin is not allow-listed for this API key"}Browser Origin not in the key's allow-list
    403{"error":"plan_upgrade_required","message":"…"} (1.6)The gym's plan doesn't cover this endpoint: non-Enterprise without the Full Website API add-on on a non-leads endpoint, below-Pro on the Leads endpoint, or a suspended / past-grace gym on any endpoint. Does not consume rate-limit budget. The message differs by endpoint class β€” branch on the code.
    429{"error":"rate_limit_exceeded","message":"Rate limit exceeded. Try again later.","retry_after_seconds":1620}Hourly budget exhausted

    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:

    json
    { "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 error code deliberately carries different message text on different endpoints: method_not_allowed reads "Method not allowed" on the display endpoints and "POST required" on the booking ones, and internal_error has several wordings depending on which step failed. The code is the contract; the sentence is for humans reading logs. Matching on message will break.

    A 429 adds one more field:

    json
    { "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:

    bash
    export MGD_KEY="…the key the gym gave you…"

    Send a test lead:

    bash
    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:

    json
    { "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:

    bash
    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):

    StatuserrorCause
    400unknown_resourceresource missing or not a supported value
    400invalid_location_id?location_id= is not a UUID
    400location_not_found?location_id= is a UUID that isn't one of this gym's branches
    403location_out_of_scope?location_id= names a different branch than the key's β€” see Β§9
    405method_not_allowedNot GET/OPTIONS (returned before auth β€” costs no quota)
    500internal_errorServer-side failure

    unknown_resource was unknown resource (with a space) before 1.2. Every error code is now snake_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):

    Field (aliases)TypeRequiredMaxNotes
    name, fullName, full_namestringyes200β‰₯ 2 chars after sanitising
    phone, phone_numberstringyes20Must be 8–15 digits after +, spaces, -, (, ) are stripped. Nothing else is stripped β€” a dot or a letter fails. Truncated to 20 chars first.
    emailstringno200Valid if supplied
    interest, service, service_intereststringno200
    notes, goal, message, commentstringno1000
    sourcestringno50Defaults to "website"
    source_details, page_url, utm_sourcestringno500
    citystringno100
    location_idstring (UUID)noβ€”(1.4) File the lead on a specific branch. A tenant-wide key may name any of the gym's branches; a branch key may name only its own. It is a sub-filter β€” it can never widen the key's scope (Β§9). Absent β‡’ the key's branch, else the gym's primary branch (unchanged behaviour).

    Send all values as JSON strings β€” a number ("phone": 9876543210) or an array/object causes a 500. 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:

    StatusBodyWhen
    201{"success":true,"message":"Lead captured successfully","lead_id":"…","action":"created","location_id":"…","location_name":"…"}New phone for this gym
    200{"success":true,"message":"Re-enquiry recorded on existing lead","lead_id":"…","action":"updated","location_id":"…","location_name":"…"}That phone already enquired β€” the existing lead is updated, not duplicated

    (1.4) location_id / location_name in the success body confirm which branch the lead was filed on (they reflect the fallback too, not just an explicit location_id in the request). Both are null only for a gym with no active branches.

    ⚠️ Treat 200 and 201 both 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 accepts 201 will report false failures to real customers. If you need to distinguish, read action.

    Errors:

    StatuserrorCause
    400invalid_jsonBody isn't valid JSON
    400invalid_nameMissing, or under 2 characters after sanitising
    400invalid_phoneNot 8–15 digits after stripping
    400invalid_emailSupplied but not a valid address
    400invalid_location_id(1.4) location_id supplied but not a UUID
    400location_not_found(1.4) location_id isn't one of this gym's branches
    403location_out_of_scope(1.4) Branch key naming a different branch (Β§9)
    401 / 403 / 429auth codesSee Β§3
    405method_not_allowedNot POST
    413payload_too_largeContent-Length over 5 KB
    500lead_write_failed / internal_errorServer-side failure β€” nothing was written

    De-duplication: matched on the last 10 digits of phone, per gym. +91 98765 43210, 09876543210, 9876543210 are the same person.

    Try it:

    bash
    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.)

    json
    {
      "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
        }
      ]
    }
    FieldTypeNotes
    pricenumberMajor units (rupees, not paise). 0 = no price set.
    intervalstring"day_pass", "month", "quarter", "half_year", "year", or "custom"
    intervalLabelstringHuman-readable, e.g. "per quarter". Derived from the plan's real duration, so it never contradicts durationDays. Can be the empty string when the plan has no duration set (interval is then "custom" and durationDays is null) β€” render price alone in that case rather than printing an empty suffix. For other custom durations it reads "for 45 days", or "per 2 months" at exactly 60 days.
    durationDaysnumber | nullThe raw plan length. Use this if you don't recognise an interval value.
    currencystringThe gym's currency β€” but a plan or package row may carry its own override, so rows in one response can differ. Read currency per row; never format the whole list from the first row.
    featuredbooleanThe owner's "most popular" pick; at most one
    locationId / locationNamestring | nullBranch. Membership plans are tenant-wide β†’ always null. Packages may be branch-scoped.

    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:

    bash
    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

    json
    {
      "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:

    bash
    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

    json
    {
      "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: 1 low / 2 medium / 3 high (anything unrecognised β†’ 2).
    • Classes have one price, so priceMember == priceNonMember. 0 = no price set.
    • sport is the class type's category and is the empty string when unset β€” never null. Note the asymmetry with services, whose category is null when unset. Treat both as "no category" rather than testing one way for both.
    • Class types are tenant-wide β†’ locationId is always null. A specific session carries a branch (below).

    Try it:

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

    json
    {
      "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).
    • id is 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.
    • templateKey is not bookable β€” it's a stable React key only.
    • dayOfWeek: 0 Sunday … 6 Saturday. startTime is "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, then startTime.
    • 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:

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

    Query paramRequiredNotes
    session_idyesUUID from a sessions response
    booking_typeyesclass or service
    is_membernoDo not send it on a service β€” that is a 422. See the box below. Accepted and ignored on a class.

    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_member truthy with booking_type=service returns 422 member_pricing_unsupported on this endpoint and on website-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):

    bash
    curl "https://db.mygymdesk.in/functions/v1/website-session-price?session_id=$SESSION_ID&booking_type=class"   -H "x-mgd-api-key: $MGD_KEY"
    json
    { "session_id":"…", "booking_type":"class", "amount":500, "currency":"INR",
      "valid":true, "location_id":"…" }
    • amount is in major units (rupees, not paise) β€” unlike website-booking-order, which returns paise.
    • location_id is the session's branch. It is null only for a branchless session, which only a tenant-wide key can reach β€” a branch key gets 403 session_out_of_scope instead (Β§9). (Added in 1.2; scoping tightened in 1.3.)

    valid:false comes back with HTTP 200 β€” it means the session exists but has no price set. Branch on valid, not on the status code.

    Errors:

    StatuserrorCause
    400invalid_session_idNot a UUID
    401unauthorizedMissing/unknown key
    403key_inactiveKey disabled
    403origin_not_allowedOrigin not on the key's allow-list
    403session_out_of_scopeThe session is not this key's branch (Β§9)
    404session_not_foundNot a session of this gym
    405method_not_allowedNot GET
    422invalid_booking_typeNot class or service
    422member_pricing_unsupportedis_member truthy on a service (1.3)
    429rate_limit_exceededOver the hourly quota; see retry_after_seconds
    500internal_errorServer-side failure

    #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:

    FieldTypeRequired
    session_idstring (UUID)yes
    booking_type"class" | "service"yes
    is_memberbooleanno β€” 422 on a service, ignored on a class (Β§5.6)
    customer{name, phone, email}no β€” recorded on the payment order for the gym's reconciliation

    customer does 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_email in the order notes) so the gym can match a payment in their Razorpay dashboard to a person. Through 1.2, name and email were accepted and silently discarded. (Fixed in 1.3.)

    Success 200:

    json
    {
      "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:

    bash
    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]"}}'
    • amount is in MINOR units (paise) β€” feed it straight to Razorpay Checkout.
    • key_id is the gym's Razorpay checkout key (their own key, or their connected-account public token). It is never null on a 200 β€” if the gym's rail can't produce a usable key you get a 503 instead, and no order is minted. Through 1.2 this could return 200 with key_id: null, which Checkout cannot open, after the order had already been created. (Fixed in 1.3.)
    • collection_method is own_pg or oauth β€” informational.

    Errors (all {"error":"<code>","message":"…"} β€” branch on error):

    StatuserrorMeaning
    400invalid_json / invalid_session_idValidation
    401unauthorizedMissing/unknown key
    403key_inactiveKey disabled
    403origin_not_allowedOrigin not on the key's allow-list
    403session_out_of_scopeThe session is not this key's branch (Β§9)
    404session_not_foundUnknown or foreign session
    405method_not_allowedNot POST
    422invalid_booking_typeNot class or service
    422member_pricing_unsupportedis_member truthy on a service (1.3)
    422session_not_pricedNo price set β€” it cannot be booked online
    429rate_limit_exceededOver the hourly quota; see retry_after_seconds
    502gateway_reconnect_requiredThe gym's Razorpay connection needs attention
    502gateway_errorThe gateway refused the order
    503gateway_not_configuredThe gym hasn't connected Razorpay, or their rail can't produce a Checkout key
    500internal_errorServer-side failure

    #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:

    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": "…"
      }
    }
    FieldTypeRequiredRules
    session_idstringyesUUID (the id from sessions)
    customer.namestringyesβ‰₯ 2 chars
    customer.phonestringyesMust be dialable for the gym's country β€” see the box below
    customer.emailstringyesValid address
    payment.gatewaystringyes"razorpay" or "paypal"
    payment.capture_idstringyesRazorpay: the razorpay_payment_id. PayPal: the capture id.
    payment.order_idstringRazorpay onlyThe order_id from website-booking-order
    payment.signaturestringRazorpay (own-keys)The razorpay_signature from Checkout
    payment.amount / payment.currencynumber / stringnoDo not send them. The server charges its own amount, resolved from the gym's data, in the gym's currency. If an older integration still sends them, they are accepted and ignored.

    Success 200:

    json
    {
      "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 / currency are the server-resolved figures β€” always the gym's non-member rate (Β§5.6).
    • lead_id is nullable β€” populated only when the booking created a brand-new member; for a returning member it's null.
    • payment_id is the payment row recorded for this booking. Every booking through this API is a paid booking β€” a zero-priced session is refused with 422 session_not_priced and 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_id and member_id are 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.phone must 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 starting 6–9, with or without a +91 / 91 prefix and any spacing you like (98765 43210, +91 98765 43210, 919876543210 all work). A number that can't be normalized is 400 invalid_phone, and no booking is created. This rule has always been enforced; it was simply undocumented before 1.3. Note it is stricter than capture-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):

    bash
    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):

    StatuserrorMeaning
    400invalid_json / invalid_body / invalid_session_id / invalid_name / invalid_phone / invalid_email / invalid_gateway / invalid_capture_id / invalid_order_idValidation
    400payment_verification_failed / payment_not_captured / amount_mismatch / currency_mismatch / invalid_signature / order_mismatchPayment did not verify β€” no booking created
    400constraint_violationA database constraint rejected the booking (defence-in-depth; no booking created)
    401unauthorizedMissing/unknown key
    403key_inactiveKey disabled
    403origin_not_allowedOrigin not on the key's allow-list
    403session_out_of_scopeThe session is not this key's branch (Β§9)
    404session_not_foundNot in this gym
    405method_not_allowedNot POST
    409slot_fullSession at capacity
    409already_bookedClass only β€” this person already has a booking on this session (see Β§13)
    409duplicate_paymentThis capture has already been used for a booking
    410session_not_bookableSession cancelled/completed
    422identity_required / session_not_priced
    429rate_limit_exceededOver the hourly quota; see retry_after_seconds
    502gateway_reconnect_required(Razorpay connected-account) the gym's Razorpay connection needs attention β€” no booking created
    503gateway_not_configuredThe gym's gateway isn't connected, so the payment can't be verified
    500internal_error / db_lookup_error / member_create_failedServer-side failure

    #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):

    ParamNotes
    location_idSub-filter within the key's scope β€” stock is then reported for that branch only. Never widens scope (Β§9).
    category_idUUID of a product category. Unknown categories return an empty list, not an error.
    brandCase-insensitive exact match on the product's brand.
    in_stock_only=trueDrops only out_of_stock products β€” low_stock stays visible.

    200:

    json
    {
      "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:

    FieldNotes
    priceThe all-in charged amount in major units, tax handling included β€” display it as-is; checkout will never show a different number.
    mrpStrike-through list price. Omitted when not set or not above price β€” don't assert on its presence.
    brand, sizeFree text, nullable. size is display-only ("1kg", "L") β€” there is no variant engine; a size run is separate products.
    stockStatus"in_stock" | "low_stock" | "out_of_stock". Exact quantities are never exposed. With a branch key or ?location_id=, this is that branch's status. For a tenant-wide key it is the whole-gym aggregate, and each product additionally carries stockByLocation: [{ "locationId", "locationName", "stockStatus" }] β€” statuses per active branch, again no counts.
    inStockConvenience boolean: stockStatus !== "out_of_stock".
    descriptionThe owner's public description when set, else the internal one.

    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:

    bash
    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:

    json
    {
      "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_id is required and must be one of the gym's active branches; a branch key may only sell its own branch.
    • customer is 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_stock carries { 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:

    json
    {
      "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:

    json
    {
      "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:

    json
    {
      "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)

    code
    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_bookable

    The server re-resolves the price, verifies the payment on the gym's Razorpay account, and books β€” you never pass an amount.

    #PayPal (capture-first)

    code
    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. The 429 itself doesn't consume budget, and CORS preflights don't β€” but any request that authenticates and then fails validation still costs one credit.
    • Every 429 body carries retry_after_seconds β€” the seconds until the window resets. Use it instead of guessing. There is no Retry-After header; 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-purchase neither check nor consume the budget, because a 429 there 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 the x-mgd-api-key header 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/ matches https://yourgym.com). List each exact origin β€” yourgym.com and www.yourgym.com are different origins.
    • A value without the scheme (www.yourgym.com) never matches. (The on-screen placeholder is scheme-less β€” ignore it and include https://.)
    • A request from an origin not on the list gets 403 origin_not_allowed. A prefix like https://yourgym.com.attacker.net does not match.
    • Server-to-server calls have no Origin header 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.com and https://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-Origin echoes your origin, Access-Control-Allow-Methods: GET, POST, OPTIONS, Access-Control-Allow-Headers includes x-mgd-api-key and content-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-lead alone answered preflight with 200 and a wildcard origin instead of 204 and 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.co host 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:

    What you callWhat happens
    website-classes?resource=sessionsOnly that branch's timetable
    website-services?resource=catalog / sessions / plansOnly that branch's rows β€” plus anything the gym offers at every branch (see below)
    website-session-price403 session_out_of_scope for another branch's session
    website-booking-order, website-class-booking, website-service-booking403 session_out_of_scope β€” no order is minted, no booking is written
    capture-website-leadThe lead files against that branch

    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:

    Row typeBranch?
    Class types (website-classes?resource=catalog)Always tenant-wide β†’ locationId: null
    Membership plans (resource=plans)Always tenant-wide β†’ locationId: null
    Services and service packagesEither β€” a service with no branch is offered everywhere and stays visible to every key
    Class / service sessionsNormally belong to one branch. A session with no branch is possible, and a branch key can neither see nor book it β€” see below.

    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 with 403 session_out_of_scope if 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:

    bash
    curl "https://db.mygymdesk.in/functions/v1/website-classes?resource=sessions&location_id=<branch-uuid>"   -H "x-mgd-api-key: $MGD_KEY"
    SituationResult
    OmittedYour key's own scope (one branch, or all of them)
    A branch inside your scopeFilters to it
    A branch outside your scope (branch key, different branch)403 location_out_of_scope
    A UUID that isn't this gym's branch400 location_not_found
    Not a UUID400 invalid_location_id
    On website-classes?resource=catalogAccepted if in scope (changed in 1.3). Class types are tenant-wide, so the result set is unchanged β€” but a well-formed, in-scope request is no longer refused. Out-of-scope still 403.

    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-price returns location_id.
    • locationName is null when the row is tenant-wide.

    #10. Recipes

    #10.1 Cached membership pricing (server-side)

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

    javascript
    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:

    javascript
    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)

    code
    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)

    code
    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:

    php
    // 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 served

    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:

    php
    // 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):

    html
    <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:

    php
    // 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-key request header to an outbound call, so they can't reach the API directly. Point their submit action at the admin-post.php handler above (or its wp_ajax_nopriv_mgd_lead twin 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

    AreaLimitation
    Rate limit30/hour by default, shared across all endpoints. Cache and raise it for a public site.
    Class rebookingA customer who books a class then cancels cannot re-book that same session through the API (409 already_booked). Fix is planned. Service bookings are unaffected.
    PricingNo member rate. Everything is quoted and charged at the gym's non-member price; is_member on a service is 422 member_pricing_unsupported. There is no way to obtain a member's own rate through this API β€” that lives in the member portal, behind a sign-in. If a gym needs member pricing on their public site, they need the member portal, not this API.
    BookingsA booking creates a permanent active member record.
    BookingsCurrently one gateway per gym (Razorpay or PayPal, whichever the gym connected).
    TimetableWeekly template only, not a dated calendar. id changes as occurrences pass. No timezone is exposed in the response (the 90-day window itself is computed in the gym's zone since 1.3). The 90-day scan reads at most 5,000 dated sessions; a gym running more than that in 90 days could lose a weekly slot from the grid (no gym is close today).
    ListsNo pagination, no filtering beyond ?location_id=, no search.
    LeadsNo spam protection and no owner notification on capture.
    KeysThe self-serve screen creates one key per gym. Per-branch keys work on the API but must be requested from support.
    Plansresource=plans covers membership plans + service packages only (not PT plans or class packages).
    PlatformNo webhooks. No sandbox/test mode for lead capture β€” use a clearly fake name and a number you control, and ask the gym to delete the test lead.

    #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 error codes are stable; message wording may be reworded for clarity. Always branch on error.
    • 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: true is 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; optional start_date today–+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 from start_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 amount is 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; price is the all-in charged amount; mrp omitted unless above price; new brand/size display fields; stock is a tri-state stockStatus (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-lead accepts an optional location_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 confirms location_id/location_name.
    • The booking-finalize endpoints are exempt from the hourly budget (Β§7). A 429 on website-class-booking / website-service-booking landed 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_hour is 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_member truthy with booking_type=service returns 422 member_pricing_unsupported on both website-session-price and website-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 with amount_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 "if price_member is 0, silently charge the non-member price" fallback is gone β€” services.price_member is no longer read by this API at all. is_member remains 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-order never returns 200 with key_id: null. The Checkout key is now resolved before the order is minted, so a gym whose rail can't produce one gets 503 gateway_not_configured and no orphan order at Razorpay. Previously the key was fetched after the order and could come back null, yielding a 200 Checkout couldn't open.
    • Everything that can reject a booking now runs before the payment is verified. The customer.phone E.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 with 400 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) and 410 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_date is 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= on website-classes?resource=catalog is accepted when it is in scope, instead of being refused outright. Out-of-scope still 403 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_applicable is retired and can no longer be returned.

    Smaller fixes:

    • customer.name / customer.email on website-booking-order are 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_member truthiness is now identical on every endpoint (true / 1 / yes). website-session-price accepted all three while website-booking-order accepted only true, so is_member=1 was honoured by the quote and ignored by the order.
    • Preflight (OPTIONS) is now 204 + echoed origin on all seven endpoints; capture-website-lead alone answered 200 + 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_priced is returned first. See Β§5.8.
    • Resolved a contradiction: 1.2 said sessions "always belong to one branch" in Β§9 while Β§5.6 said location_id could be null for a tenant-wide session. Branchless sessions do exist; the rule is now stated once, with the scoping consequence.
    • Documented: customer.phone on the booking endpoints must normalize to E.164 for the gym's country β€” a long-standing hard gate that returns 400 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_scope and 429, instead of leaving them to the blanket section in Β§3.
    • Documented: intervalLabel can 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 while category (services) is null. The asymmetry is real; handle both as "no category".
    • Documented: currency on resource=plans can be overridden per row, so two rows in one response may differ. Read it per row.
    • Made explicit that the same error code carries different message text on different endpoints β€” branch on error, never on message.

    #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 returns 403 location_out_of_scope instead of returning that branch's data. On website-classes?resource=catalog it returned 400 location_filter_not_applicable instead 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 no message now carry one, and unknown resource was renamed unknown_resource.
    • retry_after_seconds on every 429 (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-price returns location_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-price also returns 405 and 500; the timetable's real bound is 5,000 dated sessions per 90-day scan, not the spotsBooked under-count previously described.
      • payment_id in a booking response is nullable for free sessions β€” this was wrong and is retracted in 1.3. A zero-priced session is refused with 422 session_not_priced and 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.currency dropped 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_payment and creates nothing.
    • Multi-location: locationId / locationName on 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 durationDays field was added.
    • Browser calls now work from allow-listed origins; allowed_origins is 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.