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

    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. 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-orderwebsite-class-booking
    Take a service booking with paymentwebsite-booking-orderwebsite-service-booking
    Confirm the true price of a sessionwebsite-session-price

    #Endpoint inventory

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

    EndpointMethodPurposePlanRate limit
    capture-website-leadPOSTCreate/update a lead in the CRMPro¹shared 30/hr²
    website-classesGETClass catalog + weekly timetablePro¹shared
    website-servicesGETServices, service sessions, membership plansPro¹shared
    website-session-priceGETAuthoritative price for one sessionPro¹shared
    website-booking-orderPOSTCreate a Razorpay order for a bookingPro¹shared
    website-class-bookingPOSTBook a class session (after payment)Pro¹shared
    website-service-bookingPOSTBook a service session (after payment)Pro¹shared

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

    1. SettingsGrowth & AppsIntegrationsWebsite Lead Capture tab.
    2. A key is created automatically the first time the page opens. It looks like:
      code
      mgd_live_a7f3k9m2p8q1w5e6r4t7y0u3i8o2p5a9
      (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 that captured leads are filed against. It cannot be set per request and applies to lead capture only — the display and booking endpoints report each row's own branch (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. Now an exact-match security control — see §8.

    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-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: mgd_live_xxxx
    code
    ?key=mgd_live_xxxx

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

    #Authentication failures

    StatusBodyMeaning
    401{"error":"unauthorized"}Key missing, malformed, or unknown
    403{"error":"key_inactive"}Key exists but the toggle is off
    403{"error":"origin_not_allowed"}Browser Origin not in the allow-list
    429{"error":"rate_limit_exceeded"}Hourly budget exhausted

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

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

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

    StatusBodyCause
    400{"error":"unknown resource"}resource missing or not a supported value
    400{"error":"invalid_location_id","message":"location_id must be a UUID"}?location_id= is not a UUID
    400{"error":"location_not_found",…}?location_id= is a UUID that isn't one of this gym's branches
    405{"error":"method_not_allowed"}Not GET/OPTIONS (returned before auth — costs no quota)
    500{"error":"internal_error"}Server-side failure

    #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_numberstringyes208–15 digits; +, spaces, -, () stripped
    emailstringno200Valid if supplied
    interest, service, service_intereststringno200
    notes, goal, message, commentstringno1000
    sourcestringno50Defaults to "website"
    source_details, page_url, utm_sourcestringno500
    citystringno100

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

    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" — always accurate
    durationDaysnumber | nullThe raw plan length. Use this if you don't recognise an interval value.
    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.


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


    #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.
    • Class types are tenant-wide → locationId is 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.

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


    #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_membernotrue/1/yes → member price (services only)
    json
    { "session_id":"…", "booking_type":"class", "amount":500, "currency":"INR", "valid":true }

    valid:false comes back with HTTP 200 — it means the session exists but has no price. Branch on valid, 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:

    FieldTypeRequired
    session_idstring (UUID)yes
    booking_type"class" | "service"yes
    is_memberbooleanno (services only)
    customer{name, phone, email}no (prefills Checkout)

    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"
    }
    • 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). collection_method is own_pg or oauth — 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:

    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.phonestringyes
    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.
    • location_id / location_name = the session's branch.
    • lead_id is nullable — populated only when the booking created a brand-new member; for a returning member it's null. booking_id and member_id are always present.

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

    #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, 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. 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.
    • No Retry-After header (except a retry_after_seconds body field on capture-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 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 with no Access-Control-Allow-Origin, so the browser blocks it. A prefix like https://yourgym.com.attacker.net does 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>:

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

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

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

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

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

    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.
    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. spotsBooked can under-report on very busy gyms.
    ListsNo pagination, no filtering beyond ?location_id=, no search.
    LeadsNo spam protection and no owner notification on capture; the key's branch can't be overridden per request.
    KeysOne key per gym — no per-branch keys, no way to add a second. A multi-location gym's leads all file against one configured branch (re-assign in the dashboard).
    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.

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