Frontdesk Booking System
Our backend that lets a business take bookings into Mindbody through our own channels — starting with an embeddable website widget. This document explains, in plain terms, how a business connects to us, how we authenticate with Mindbody, and how a booking actually happens.
v1 · book without taking payment real Mindbody add-ons per-customer price · member / intro / standard connect via activation code no OAuth (it's consumer-level)
What this is
the one-paragraph version
Today, a business that uses Mindbody can only take online bookings through Mindbody's own pages. We want to let them take bookings through our surfaces instead — a booking widget on their own website, our AI front-desk agent, and so on — while the appointment still lands in their Mindbody calendar.
To do that, our backend speaks to Mindbody on the business's behalf. Everything Mindbody-specific is hidden inside one place in our code (the "adapter"), so the same booking widget can later work for Square, Vagaro, Acuity and others without being rewritten.
A studio pastes one line of code on their website. A customer books on that website. The appointment shows up in the studio's Mindbody — no Mindbody login screen, no redirect.
The big picture
five hops from website to Mindbody
A small piece of JavaScript a studio adds to their website. It shows services, times, and a booking form.
The new backend endpoints the widget talks to. Open to the public (no login), but tied to one specific studio.
The single place in our code that knows how to talk to Mindbody. Swappable for other booking platforms.
Plain-English glossary
terms used below, defined once
Mindbody's ID for a specific business location (e.g. -99787). We need it to point our calls at the right studio.
A key Mindbody gives us as a developer. It identifies our app on every request. We already have one.
The studio's act of granting our app permission to access their Mindbody data. This is the "consent" step.
A short-lived pass we get from Mindbody (per studio) that authorizes each call. Auto-renews behind the scenes.
An optional extra attached to a service (e.g. a hydrating eye mask on a lash appointment).
A specific bookable time with a specific provider.
Onboarding options
how a studio gets connected to us
"Onboarding" = the one-time setup where a studio links their Mindbody to us. There are three realistic ways to do it. The first is what we do today and want to move away from.
We open a remote browser, the studio logs into Mindbody inside it, we read their Site ID off the page, then auto-click the activation link. Works, but heavy and fragile (it depends on Mindbody's page layout).
The studio enters their Site ID; we generate an activation code; they paste it into Mindbody's own API-settings screen. No browser automation, nothing to break.
We generate an activation link; the studio just clicks it while logged into Mindbody. Lowest effort for them, but still relies on them being logged in.
The cleanest path is: studio tells us their Site ID → we hand them a code → they paste it into Mindbody → done. No screen-scraping, no remote browser.
How we authenticate with Mindbody
the three things that let our server make calls
Once a studio is connected, every call our backend makes to Mindbody is authorized by a combination of three things. None of them is the studio's password — we never see that.
Identifies our app on every request (a fixed header). We already have this from Mindbody.
The studio's one-time consent that grants our app access to their Site ID. This is the "yes, you may" step.
A per-studio bearer token we mint using our partner staff credentials. Sent with every call (with the Site ID). Expires and auto-renews.
API Key says "this is Zoca's app." Activation says "this studio allowed Zoca in." User token is the day-to-day pass that gets stamped on every request. That's the whole auth story for our backend.
Why not OAuth?
a deliberate decision
OAuth is the "Log in with…" flow. In Mindbody it is primarily a consumer-level mechanism — an individual customer granting an app access to their own activity (visits and purchases) through the Consumer Activity API. When you create an OAuth client, its "Client Use for" setting is literally "Consumer API Access."
It is technically possible to have an OAuth client specially provisioned with the Mindbody.Api.Public.v6 scope to reach business data — but that requires extra setup from Mindbody and is not the standard way partners integrate.
For our backend (server-to-server access to a business's data) we use the established API Key + activation + user token model, not OAuth. OAuth's main use case — an individual customer logging into Mindbody to manage their own bookings — doesn't apply to this system, so we don't take on its extra setup.
The customer journey
what a person booking on the studio's website experiences
This mirrors the prototype. Each step lists, in plain terms, what the backend does behind it.
Open the widget
The widget loads on the studio's site, figures out which studio it belongs to, and starts the FrontDesk follow-up funnel.
Pick a service
Services shown by category, with duration, price, hasAddons, and embedded per-service addons. Category order follows the Mindbody Program number prefix (1., 2., etc. → order_index); the widget strips the prefix for display.
Add extras (when the service has them)
Optional service add-ons for the chosen service. This screen comes before staff and is skipped when hasAddons is false.
backend: read embedded service addons from /config or /services built
Choose a provider
A staff member, or "First Available." Master Lash Artist is a staff surcharge surfaced on /staff as surcharge, not a service add-on.
Pick a date & time
Open times for that service/provider, shown in the studio's timezone.
backend: fetch real availability from Mindbody needs fixes
Hold the slot
The "reserved for 10:00" timer, so two people can't grab the same time.
backend: place a temporary hold new
Enter details
Name and phone (with SMS consent). We match or create the customer in Mindbody.
Confirm
Review and book. Promo codes verified in the widget are sent as promoCode to /book, stamped into the booking note, and applied by the front desk at checkout — not deducted in the widget.
Done
Confirmation screen + emailed details.
The prototype takes no card and no deposit — it's "pay at the venue." So the whole payment/security side can wait. The only money-adjacent piece in v1 is the 10-minute slot hold, which is just a reservation, not a charge.
Pricing model
the price a specific customer actually pays — resolved before they book
The widget does not show one catalogue price. It shows the price this customer pays: their tier (member / intro / standard), any staff surcharge, and whether a promo code is valid. Nothing is charged online — the widget creates the booking only, and the resolved summary goes into the booking note for the front desk to apply at checkout.
Tier prices, service add-ons, staff surcharges, and category order are frozen into our mirror tables by Catalog sync. The display path makes zero live Mindbody calls — only client identify, availability, promo validation, and the booking itself touch Mindbody live.
Three tiers, resolved up front
Every customer lands in one tier, decided at Screen 1 · Identify from a live lookup — deterministic, not a guess. The labels are the same everywhere; the dollar amounts come from each location's mirror.
Zero appointment history in the studio's Mindbody. Gets introductory pricing on the first appointment. A client record alone (an inquiry, a gift card) does not forfeit intro.
Has booked before, no active membership. Pays the standard catalogue price — no slash.
Active membership → member rate, and possibly included credits (below).
Look up the client (phone / email)
Pick the tier
Active membership → Member. Else appointment history → Returning · Standard. Else → New · Intro.
Resolve the amount
Add the staff surcharge
Members may have included credits (e.g. one full set a month). When the API reports a remaining credit that covers the service we show “Expected in studio: $0.” If the credit data is not reliable in real time we fall back to the softer “Members with benefits: apply in studio” — we never show $0 and then have the studio charge.
What the customer sees
validated on Deka Wexford · Classic Full Set — Standard $180 · Intro $119.99 · Member $135
| State | Price screen |
|---|---|
| Member, no credit | |
| Member, included credit covers it | “Expected in studio: $0” (or the “apply in studio” fallback) |
| New · Intro | |
| Returning · Standard | $180.00 — no slash |
| Service has add-ons | Add-ons screen before staff; skipped when hasAddons is false |
| Master Lash Artist selected | + $20.00 Master Lash Artist as its own line, on top of the tier price |
| Promo valid | Code shown as verified · “applied at checkout” · widget total does not drop |
| Promo invalid | Inline “not a valid code” · price unchanged |
- Slot taken during review → hold expired; re-pick a slot before confirm.
- Promo verification fails / Mindbody slow → keep booking available; show “promo can be confirmed in studio.”
- Client create fails → surface the error, never half-book.
Staff surcharges are price modifiers surfaced on /staff (never a menu choice). Service add-ons are bookable extras embedded per service on /config and /services; they power the add-ons screen. Neither is ever in the other's payload.
Where each number comes from
Zoca is the menu · the mirror is the pricing engine · Mindbody is the booking engine
| Data | Where | Why |
|---|---|---|
| Service catalogue, categories, base price | Zoca DB synced | Static menu — first paint |
| Tier prices (standard / intro / member) | Mirror mindbody.service_pricing_tiers | Frozen at sync; instant read |
| Service add-ons | Zoca DB services.service_addons, embedded on /services | Static per service |
| Staff + surcharge | Zoca DB staff.members + surcharge rule | Deterministic per provider × service |
| Holds, lead funnel, tracker, prefs | Zoca DB our state | Booking lifecycle we manage |
| Client identity + tier | Mindbody live clients · memberships · appointment history | Per-client, changes over time |
| Open slots | Mindbody live bookableitems | Real-time availability |
| Promo validity | Mindbody live /site/promocodes | Validation only — never deducted |
| The booking | Mindbody live addappointment | Puts it on the calendar |
Latency budget
per-call targets at the widget (including our hop) — Instant = our DB, Live = one Mindbody round-trip, Heavy = scrape or multi-leg
| Call | Tier | p50 | p95 | Blocks the user? |
|---|---|---|---|---|
| Services / staff (catalogue) | Instant | 80 ms | 200 ms | No — first paint |
| Config (cached prefs) | Instant | 150 ms | 400 ms | No |
| Hold (blocked slot) | Instant | 120 ms | 350 ms | Brief — on slot select |
| Tier price · add-ons · staff surcharge (mirror) | Instant | 60 ms | 150 ms | No — one Postgres read |
| Client resolve + membership | Live | 500 ms | 1.2 s | Yes — after identify |
Availability (bookableitems) | Live | 600 ms | 1.5 s | Yes — calendar load |
Promo verification (/site/promocodes) | Live | 400 ms | 1.2 s | No — booking can continue |
| Slots (scraper-first) | Heavy | 900 ms | 2.5 s | Yes — 30 s cache softens |
Confirm (addAppointment) | Live | 700 ms | 1.6 s | Yes — one tap, spinner |
Render every screen from our DB first (services, staff, tier price), then resolve the live values into placeholders. The customer never stares at a blank screen waiting on Mindbody — client identify is the one live call that gates the UX, and it happens once.
What the backend does (at a glance)
most of it already exists
| Step | Built already? |
|---|---|
| List services & settings | yes — embeds addons + hasAddons |
| Add-ons step | yes — from service payload, skipped when empty |
| List staff | yes — includes surcharge for staff upgrades |
| Get available times | yes — but needs accuracy fixes |
| Hold a slot | new — small |
| Verify promo | yes — validates against Mindbody promo codes |
| Lead funnel | yes — lead → service selected → slot selected → booked |
| Find/create customer | yes — reuse |
| Create the booking | yes — reuse |
API reference & contracts
what we shipped, and the shape of every call
All endpoints at a glance
| Method | Path | What |
|---|---|---|
| POST | /auth/mindbody/register | Register site + connect (→ activation code or connected) |
| GET | /auth/mindbody/activation-status | Poll-safe activation check |
| POST | /relay/api/v1/integrated-platforms/:entityId/sync-catalog | Sync pricing mirror (service_meta, tiers, addons, staff links) |
| GET | /frontdesk/widget/:entityId/config | Bootstrap: first page of services + categories + prefs |
| GET | /frontdesk/widget/:entityId/services | Paginated + searchable services; each service embeds addons + hasAddons |
| GET | /frontdesk/widget/:entityId/staff | Providers with staff surcharge (?serviceId= → only who does it) |
| POST | /frontdesk/widget/:entityId/identify | Resolve pricing tier from phone/email (live Mindbody lookup) — returns tier only, no client id |
| POST | /frontdesk/widget/:entityId/price | Tier price + staff surcharge from mirror; promo is not deducted here |
| POST | /frontdesk/widget/:entityId/verify-promo | Validate promo against Mindbody /site/promocodes; no widget-side deduction |
| GET | /frontdesk/widget/:entityId/availability | Dates with openings (one ranged call) + next available |
| GET | /frontdesk/widget/:entityId/slots | Open times for a service on one date |
| POST | /frontdesk/widget/:entityId/hold | Reserve a slot (~10 min) for a session |
| POST | /frontdesk/widget/:entityId/lead | Create/update FrontDesk lead and start follow-up funnel |
| POST | /frontdesk/widget/:entityId/funnel | Mark funnel milestone: service selected, slot selected, booked |
| POST | /frontdesk/widget/:entityId/client | Find-or-create the platform customer |
| POST | /frontdesk/widget/:entityId/book | Create the appointment (idempotent via holdId); accepts promoCode |
| POST | /frontdesk/widget/:entityId/card-status | Requirement gate + on-file check (yes/no only — no brand/last-four) |
| POST | /frontdesk/widget/:entityId/collect-card | Mint one-time PCI token for inline PAN POST to PCI lambda |
The widget system is platform-agnostic by design. The customer-facing surface never names a booking platform: it speaks our own vocabulary and reads from our own database. A specific platform — Mindbody first — is an implementation detail that lives behind the booking layer.
Catalogue is ours; booking is the platform's. Anything we show — services, staff, prices, categories — is served straight from our own catalogues (ServicesService, StaffService) in our canonical types. Anything that touches the calendar — availability, holds, customers, the booking itself — is resolved through the IntegratedPlatformsService down to the connected platform. Swap Mindbody for another platform and the widget, its payloads, and these contracts don't change.
api-server · authenticated (studio dashboard). Links a studio's platform account to a Zoca entity and drives activation. Base: /auth.
tasks-server · @Public, business-scoped by :entityId. Powers the embeddable widget. Base: /tasks/api/v1/frontdesk/widget.
Catalogue reads come from our DB (populated at connect-time by syncServices / syncStaff). The platform facade's catalogue reads are now deprecated.
Onboarding & connect
api-server · authenticated
Registers the studio's site against the entity and attempts to connect. Returns activation_required (with a code/link) when the studio still has to grant access; otherwise connects and kicks off the catalogue sync.
Requestjson{ "entityId": "uuid", "siteId": "28902" }
Response: activation requiredjson{ "result": "activation_required",
"activationCode": "fGjY7C/tBOsd2+K6wfgqfw==",
"activationLink": "https://clients.mindbodyonline.com/classic/ws?auto=studioId=28902&code=..." }
Response: connectedjson{ "result": "Mindbody connected successfully" }
Poll-safe completion check after activation_required. Re-tries token issuance only (never regenerates the code), so the dashboard can poll it cheaply while the studio activates. On success it flips the integration valid, auto-binds a single location, and triggers syncServices + syncStaff.
RequesthttpGET /auth/mindbody/activation-status?entityId=uuid&siteId=28902
Response: pendingjson{ "result": "activation_pending" }
Response: connectedjson{ "result": "Mindbody connected successfully" }
The widget speaks only our ids — serviceId and staffId are Zoca UUIDs. The platform referenceId never leaves the backend; we translate it internally for the platform calls. Omit staffId anywhere = "Any staff" (the backend allocates a free provider).
Public widget — catalogue (our DB)
tasks-server · @Public · our canonical types
Widget bootstrap: the first page of services + the category list + booking preferences. Services come from our catalogue (ServiceWithCategoriesDto) and now embed per-service addons plus hasAddons; page the rest via /services. Category order is preserved from the Mindbody Program number prefix (1., 2., etc. → order_index), while the widget strips that prefix for display. Preferences start from the platform adapter, then preferences.cardRequirement is overlaid from frontdesk.card.requirement in EAV — the same value /book and /card-status enforce.
Responsejson{ "entityId": "uuid",
"services": [ { "id": "uuid", "name": "Classic Full Set",
"duration": { "value": 60 },
"price": { "value": 65, "metadata": { "currency": "USD" } },
"categories": [ { "id": "uuid", "name": "Full Sets", "orderIndex": 1 } ],
"hasAddons": true,
"addons": [
{ "id": "uuid", "name": "Hydrating Eye Mask",
"priceCents": 1500, "durationMinutes": 10 }
],
"attributes": { "platformServiceId": "298" } } ],
"servicesTotal": 47, "servicesPage": 1, "servicesLimit": 20,
"categories": [ { "id": "uuid", "name": "Full Sets", "orderIndex": 1 } ],
"preferences": { "onlineBookingEnabled": true, "cancellation": { "windowHours": 24 },
"cardRequirement": "first_time" } }
Paginated + searchable services (for large menus). Thin pass-through to ServicesService. limit caps at 50. Each service carries hasAddons and an embedded addons array, so the widget does not need a separate add-ons fetch in the booking flow: show the add-ons screen immediately after service selection when hasAddons is true; skip straight to staff when it is false.
RequesthttpGET /frontdesk/widget/:entityId/services?page=1&limit=20&search=massage
Responsejson{ "services": [
{ "id": "uuid", "name": "Classic Full Set",
"hasAddons": true,
"addons": [
{ "id": "uuid", "name": "Hydrating Eye Mask",
"priceCents": 1500, "durationMinutes": 10 }
] }
],
"totalCount": 47, "page": 1, "limit": 20, "hasMore": true }
Bookable providers, straight from StaffService (IStaffMember[]). The FE uses our id; referenceId is internal (the platform staff id). Pass ?serviceId=uuid to narrow to staff who actually perform that service (via active services.staff_services rows). Master Lash Artist is returned as a staff surcharge on the provider (surcharge), not as a service add-on and not inside the service addons array. /staff never returns the full roster — even without serviceId, or when a service has no staff_services mapping, it degrades to staff with ≥1 active assignment (genuine providers only).
RequesthttpGET /frontdesk/widget/:entityId/staff?serviceId=uuid
Responsejson{ "staff": [
{ "id": "uuid", "firstName": "Surry", "lastName": "Singh",
"referenceId": "100000000", "isBookableOnline": true,
"isServiceProvider": true, "title": null, "specializations": [],
"surcharge": { "amountCents": 2000, "label": "Master Lash Artist" } }
] }
The widget booking path reads service add-ons directly from /config and /services. Master Lash Artist is intentionally not in those add-ons; it is a staff surcharge returned by /staff.
Public widget — booking (platform)
tasks-server · @Public · resolved through the adapter
Availability across a window, grouped by date — only dates that have openings (the first is the next-available day), each carrying its slots. Resolved in one ranged platform call (no per-day requests), so the calendar shows just the days worth picking and day-clicks need no follow-up call.
YYYY-MM-DD format.RequesthttpGET /frontdesk/widget/:entityId/availability?serviceId=uuid&from=2026-06-19&days=14
Responsejson{ "serviceId": "uuid", "from": "2026-06-19", "window": 14,
"dates": [ "2026-06-19", "2026-06-20" ],
"days": [ { "date": "2026-06-19",
"slots": [ { "time": "10:00 AM", "endTime": "11:00 AM",
"staffId": "uuid", "staffName": "Surry Singh",
"providers": [ { "staffId": "uuid", "name": "Surry Singh" } ] } ] } ],
"nextAvailable": { "date": "2026-06-19", "slots": [] } }
Open times for a service on a single date (the per-day primitive; the widget uses /availability instead). We resolve our serviceId → platformServiceId, then ask the platform for live availability. Omit staffId = "Any staff" (one slot per time with a providers[] list). Providers are mapped back to our staff ids.
YYYY-MM-DD format.RequesthttpGET /frontdesk/widget/:entityId/slots?serviceId=uuid&date=2026-06-19
Responsejson{ "date": "2026-06-19", "serviceId": "uuid",
"slots": [ { "time": "10:00 AM", "endTime": "11:00 AM",
"staffId": "uuid", "staffName": "Surry Singh",
"providers": [ { "staffId": "uuid", "name": "Surry Singh" } ] } ],
"totalSlots": 8, "hasOpenings": true }
Reserve a slot for ~10 minutes (the "held for you" timer) via scheduling.blocked_slots. Omit staffId = any staff (holds the first free provider; concurrent any-staff holds land on different providers). Pass a stable sessionId so re-picking releases your own prior hold instead of colliding.
10:00 AM.Requestjson{ "date": "2026-06-19", "slot": "10:00 AM",
"serviceId": "uuid", "staffId": "uuid?", "sessionId": "uuid" }
Responsejson{ "holdId": "uuid", "staffId": "uuid", "expiresAt": "...Z",
"startTime": "...Z", "endTime": "...Z", "timezone": "America/New_York" }
Find-or-create the platform customer by phone (optional — book resolves it too, but the widget can validate the number early / surface "welcome back").
Requestjson{ "phone": "+15551234567", "name": "Jane Doe", "email": "jane@x.com" }
Responsejson{ "success": true, "platformClientId": "100045", "message": "Client ready" }
Validates a promo code against Mindbody /site/promocodes. This is a validation step only: a valid code is shown as accepted in the widget, then sent to /book as promoCode, stamped into the appointment note, and applied by the front desk at checkout. The widget does not deduct the discount from the displayed total.
Requestjson{ "promoCode": "WELCOME10" }
Response: validjson{ "valid": true,
"code": "WELCOME10",
"discountType": "percent",
"discountAmount": 10,
"message": "Promo code will be applied at checkout." }
Response: invalidjson{ "valid": false,
"code": "WELCOME10",
"discountType": null,
"discountAmount": null,
"message": "Promo code not found." }
Create the appointment. Pass the holdId from /hold: book uses the held provider and is idempotent — a retry on an already-booked hold returns alreadyBooked (no duplicate). Omit staffId = any staff (tries each free provider, skipping SLOT_TAKEN). The client is resolved by phone in the adapter. If the customer entered a valid promo, pass promoCode; the booking note is stamped with that code for at-venue checkout.
10:00 AM./hold; used for idempotent booking./verify-promo. Stamped into the booking note and applied at checkout; not deducted in the widget.Requestjson{ "phone": "+15551234567", "name": "Jane Doe", "email": "jane@x.com",
"date": "2026-06-19", "slot": "10:00 AM",
"serviceId": "uuid", "staffId": "uuid?", "holdId": "uuid",
"promoCode": "WELCOME10", "note": "optional" }
Responsejson{ "success": true,
"appointmentId": "100098",
"bookingStatus": "BOOKED",
"message": "Appointment booked" }
Retry responsejson{ "success": true, "alreadyBooked": true, "message": "Already booked" }
Connects the widget to the FrontDesk lead follow-up funnel. /lead creates or updates the lead as soon as the widget has contact information. /funnel records milestones as the customer advances: lead → service_selected → slot_selected → booked. When /book succeeds, the funnel is marked booked and pending follow-ups cancel automatically.
/funnel: lead, service_selected, slot_selected, or booked.service_selected or later.slot_selected or later.Request: leadjson{ "phone": "+15551234567",
"email": "jane@x.com",
"name": "Jane Doe" }
Request: funneljson{ "phone": "+15551234567",
"stage": "service_selected",
"serviceId": "uuid" }
Responsejson{ "success": true,
"stage": "service_selected",
"followUpActive": true }
Public widget — card on file
tasks-server · @Public · PCI-clean card vault
Returns the studio's card requirement from frontdesk prefs (always / first_time / never, the same EAV value /config overlays), whether it's required for this client (always, or first_time and tier === 'new'), and whether the client already has a card on file. Email-only lookups return clientFound: false — no unfiltered platform search is performed.
This is a public, unauthenticated endpoint, so the response is card-metadata-free by design: no card brand, no last-four, no platformClientId. "Does this phone number have a Visa ending 1234" is phishing material on a route anyone can hit; the widget only needs the boolean hasCardOnFile to decide whether to show the capture form.
email is omitted./identify (new / returning / member); needed to resolve required under a first_time policy.Response: on filejson{ "requirement": "always",
"required": true,
"supported": true,
"clientFound": true,
"hasCardOnFile": true }
Response: not on filejson{ "requirement": "always",
"required": true,
"supported": true,
"clientFound": true,
"hasCardOnFile": false }
Response: email-only (no unfiltered search)json{ "requirement": "first_time",
"required": false,
"supported": true,
"clientFound": false,
"hasCardOnFile": false }
Mints a one-time PCI token and PCI lambda target for inline card capture. There is no iframe and no hosted card-page redirect in the widget: the widget renders the PAN fields inline and POSTs the raw PAN directly to the PCI lambda — the card number never touches our servers. The lambda saves the card in the platform's vault (cardOnly mode, no booking) and redirects or callbacks to completeUrl.
email is omitted.Responsejson{ "supported": true,
"token": "abc123",
"submitUrl": "https://pci.zoca-tech.com/card",
"bookingRef": "uuid",
"completeUrl": "https://widget.zoca-tech.com/card-saved" }
Visa test card collected end-to-end: /card-status confirmed hasCardOnFile: true on file (Mindbody itself reported Visa ••1234 internally — the API response is now card-metadata-free, see Security hardening); /collect-card minted a PCI token; the inline widget form posted PAN directly to the lambda; the lambda vaulted the card (cardOnly mode, no booking created).
The Mindbody layer
what "the platform" means behind the curtain
Everything above is platform-neutral. This is the Mindbody-specific work that sits behind the booking layer — the only part that knows Mindbody exists.
registerSite records the site ⇄ entity link, then we probe access with issueUserToken(siteId). If the studio hasn't granted us access yet, getActivationCode returns a real code/link they approve once — no OAuth, no browser-auth.
Access uses our partner Api-Key + source staff credentials (_ZocaAI) against /usertoken/issue; the Bearer token is cached in auth.mindbody and renewed as needed.
On connect we sync the site, locations, services, and staff into our DB (syncServices / syncStaff) — so the widget reads our catalogue, not Mindbody live. platformServiceId / referenceId are stored on each row to bridge back for booking.
A separate sync pipeline (syncCatalog) freezes tier pricing, service add-ons, staff surcharges, and category ordering into mirror tables. See Catalog sync below. Decoupled from the service/staff sync so it can run independently (nightly or on demand).
Single-location sites are auto-bound (product_entities productId 13); multi-location sites use the picker. Booking resolves the physical location from this bind.
At connect we record whether the site can charge and which cards it accepts — the seam for Phase-2 deposits / no-show protection.
Bookable items and session types are fetched with pagination + caching, and grouped by category, so large menus stay fast.
The full flow runs against live Mindbody sites: register → activate → sync services/staff → sync catalog (pricing mirror) → widget serves tier-priced catalogue from our DB → MLA surcharge from mirror → booking through the adapter.
Catalog sync
freeze pricing & add-ons into mirror tables — once at sync, then instant at serve-time
The catalog sync is the keystone of the pricing architecture. It fetches Mindbody's full session-type and sale-service catalogue, runs all fragile name/program matching once, and stores the results in mirror tables that serve the widget with pure Postgres lookups.
Frozen SessionType identity — sessionTypeId, programId, categoryId, subcategoryId, onlineOnly — keyed to our serviceId / serviceVariationId. The reverse-map anchor: booking reads sessionTypeId here instead of re-matching by name. Also the source for subcategoryId used to auto-link suggestable service add-ons.
One row per (serviceMetaId, tier) with tier ∈ {standard, intro, member}. Stores saleServiceId (the MB Sale service id needed to build the checkout cart) + priceCents. Frozen at sync from IsIntroOffer, RestrictToMembershipIds, and name matching within ProgramId.
Platform-agnostic add-on entity for service add-ons shown before staff. Platform ids are stored in sourceId (upsert key) + attributes jsonb — not coupled to Mindbody.
Add-on ↔ service link. linkSource = 'auto' when synced from Mindbody's SubcategoryId match; 'manual' for ops-curated links.
Staff surcharge link. The MLA designation is resolved from Mindbody staff assignment data, but it is surfaced to the widget as /staff[].surcharge, not as a service add-on choice.
Mindbody Program names carry a numeric prefix (1. Full Sets, 2. Refills). Sync extracts that prefix into order_index; the widget sorts by it and strips the N. prefix for display.
Audit log for add-on mutations. Each row mirrors the addons columns and adds a log_id serial primary key, an updated_at_timestamp, and an updates_json jsonb diff. Written automatically on every add-on upsert during sync so the full change history is preserved.
OnlineOnly is not a field on Mindbody's SessionType object — it must be derived. The sync fetches session types twice: all types + OnlineOnly=true filter. Types that appear in the online-only set are marked isBookableOnline = true. MLA upgrade data is treated as a staff surcharge, not a customer-selectable service add-on.
services.addons and services.service_addons are platform-agnostic by design. Mindbody ids live in sourceId + attributes jsonb. Future platforms (Square, Vagaro) populate the same tables with their own sourceIds — the widget never changes. Staff upgrades are resolved separately and returned as staff surcharge.
The mirror is frozen at sync time. If a studio changes a price, an intro offer, a service add-on, a staff surcharge, or Program ordering inside Mindbody, /price, /services, and /staff keep serving the old mirror values until sync-catalog runs again. There is no nightly cron yet — today this means manually re-running POST /relay/api/v1/integrated-platforms/:entityId/sync-catalog after any Mindbody-side edit.
Trigger sync (relay server)bashcurl -s -X POST \
http://localhost:3002/relay/api/v1/integrated-platforms/$ENTITY/sync-catalog
Responsejson{
"serviceMeta": 27,
"pricingTiers": 81,
"addons": 20,
"serviceAddonLinks": 20,
"staffLinked": 48
}
Choice · Where slots come from
how we know what times are open
The proper endpoint that returns real open times, already checked against the service length. Needs two accuracy fixes (paging + sorting).
What we do now. Fine for the AI agent, but too fragile to power a customer-facing website.
Pull the raw schedule and compute openings in our code. Avoid — it re-does work Mindbody already does for us.
Card-on-file flow
requirement gate → check on file → collect if needed — PCI-clean
The full card-on-file flow — requirement gate, on-file check, PCI token mint, in-page card capture, and Mindbody vault save — is built and confirmed against real Deka data.
The widget inserts a card-on-file gate between price confirm and booking. The flow has three stages:
Check requirement + on-file status
If requirement is never: skip this screen and proceed to booking. If requirement is always or first_time and hasCardOnFile is true, show a generic "Card on file" and proceed — the API deliberately doesn't return brand or last-four (see Security hardening). Only if a card is needed and not present do we collect one.
Collect the card (when needed)
/collect-card returns a PCI token/submit target. The widget renders the PAN fields inline — no iframe — and POSTs the PAN directly to the PCI lambda (cross-origin, never through our API). The lambda vaults the card in Mindbody in cardOnly mode (no booking), then redirects or callbacks to completeUrl.
Book
The booking proceeds normally. The card is already vaulted; the front desk sees it at in-person checkout.
The card number is typed into inline widget fields that POST directly to the PCI lambda — our API never sees it, and there is no iframe. Mindbody returns the last-four and card type after it vaults the card, but our public /card-status endpoint deliberately doesn't forward that detail to the browser — see Security hardening.
The PCI lambda's toContext whitelist previously dropped the cardOnly flag, causing the lambda to attempt a booking instead of a vault-only save. This was fixed in pci-stack PR #12 — the lambda now correctly routes cardOnly requests to vault the card and skip the booking.
Charging cards · no-show fees & deposits required later phase
what's actually needed to take money
This is not optional. No-show fees and deposits are a product requirement — scheduled for a later phase, but v1 must be built so they slot in without a rewrite. The seams listed at the end of this section must exist from day one.
Good news: charging a card does not need Mindbody Payments / Connect. You charge through the Public API (CheckoutShoppingCart) against whatever active merchant account the studio already has (TSYS, Bluefin, Elavon, Paysafe, Adyen, Ezidebit, or Mindbody Payments — any one). Connect is only for tokenizing a card so we never see the raw number; it is not required to take the money.
The real difference between a no-show fee and a deposit is when the card gets captured:
Card is already on file (stored at booking) → just charge it later with CheckoutShoppingCart. No new card capture. This is the easy one.
Customer hasn't given a card yet → we must capture it during the widget flow, then charge. Same charge call, but the capture is the PCI-sensitive step — which is exactly where the Connect tokenization / PCI-enclave choice comes back in (so the raw card never touches the studio's site or our servers).
Charging = Public API + an active merchant account (no Connect). No-show is easy because the card is already stored. A deposit adds one thing: safely capturing the card in the widget first.
(1) Hard gate — the studio must have an active Mindbody merchant account, or the API can store a card but not charge it. (2) We make no charge calls today — this is new work. (3) The exact "pay with card on file" cart payload still needs confirming in the live docs, and SCA/3-DS may apply. (4) Deposit reconciliation (how it reduces the at-venue balance and is refunded on cancellation) is a separate decision from "can we charge."
What v1 must leave open (so this isn't a rewrite later)
- Card-capture seam — even though v1 takes no money, design the booking flow so a card-capture step (Connect token or PCI enclave) can be dropped in without reworking the widget or the booking API.
- Merchant-account awareness — during onboarding, detect & record whether each studio can actually charge (active processor / Mindbody Payments), so the widget knows when deposits/no-show are even possible.
- Adapter seams — reserve
captureCard?,chargeNoShow?,chargeDeposit?+ capability flags on the platform adapter (optional methods, like the ones it has today). - Data model — a home for the no-show / deposit policy (per service or location), the stored card-on-file reference, and a charge / refund audit trail.
- Confirm early — the
CheckoutShoppingCart"pay with card on file" payload + SCA behaviour, and the deposit refund / reconciliation rules, before Phase 2 starts.
Implementation reference
The build, screen by screen — file map, per-screen behavior, cross-cutting machinery, and testing evidence. Branch feat/mindbody-widget-pricing.
the mirror-cache architecture, and the one safety rule
Pricing and booking run from our own Postgres mirror — zero live Mindbody calls for the browse and base price path. The key design insight: all fragile name/program matching happens once at sync time, and booking becomes pure reverse-map lookups. Promo codes are the exception: they are validated against Mindbody /site/promocodes, then stamped into the booking note for checkout instead of deducted in-widget.
Nothing writes to Mindbody in development. Every mutation is gated by assertMindbodyWriteAllowed() — see § Dev write-guard. Reads and promo verification are non-mutating and stay live.
Every endpoint below is @Public (no login). Rate limiting, CORS allow-listing, and minimal response shapes (/identify, /card-status) stand in for auth today — see Security · public-widget hardening in the design doc for the full picture and its honest limitation.
Legend used per screen: Zoca DB = served from our database (instant) · Mindbody (live) = a real-time API call · Mirror = our synced Postgres mirror tables · stored = written to our DB.
File map
| Piece | File |
|---|---|
Add-on entity (services.addons) | libs/drizzle-orm/.../schemas/services/addons.table.ts |
Service↔add-on link (service_addons) | libs/drizzle-orm/.../schemas/services/service-addons.table.ts |
| Staff surcharge surfacing | libs/integrated-platforms/.../types/standardized.types.ts |
Mindbody reverse-map anchor (service_meta) | libs/drizzle-orm/.../schemas/mindbody/service-meta.table.ts |
Tier price cache (service_pricing_tiers) | libs/drizzle-orm/.../schemas/mindbody/service-pricing-tiers.table.ts |
| Category order sync | Mindbody Program number prefix → order_index in service/category payloads |
| Migration (all new tables) | migrations/0468_addons_and_mindbody_mirror.sql |
| Mindbody types | libs/mindbody/src/lib/mindbody.model.ts |
Facade types (StaffAddonSurcharge) | libs/integrated-platforms/.../types/standardized.types.ts |
| Sync pipeline | libs/integrated-platforms/.../adapters/mindbody.adapter.ts |
| Sync repository (upserts) | libs/integrated-platforms/.../repositories/platform-sync.repository.ts |
| Facade | libs/integrated-platforms/.../integrated-platforms.service.ts |
| Sync endpoint | apps/servers/relay-server/.../integrated-platforms.controller.ts |
| Widget endpoints | apps/tasks-server/.../frontdesk/frontdesk-widget.controller.ts |
| Lead funnel endpoints | apps/tasks-server/.../frontdesk/frontdesk-widget.controller.ts (/lead, /funnel) |
Screen 1 · Identify
phone + name → resolve the pricing tier
The new first screen. We look the customer up and classify them — read-only, never creating a client.
getClients + activeclientmemberships + an appointment-history check.{ supported, platform, tier: 'new' | 'returning' | 'member', isExistingClient, includedCredits } — no platformClientId, no membership objects.platformClientId and the membership list are resolved server-side but never returned — this is an unauthenticated route. Every screen after this one (/price, /card-status, /collect-card, /book) re-resolves the client by phone on the backend instead of trusting an id round-tripped through the browser. See Security · public-widget hardening.
RequesthttpPOST /tasks/api/v1/frontdesk/widget/:entityId/identify
Responsejson{ "supported": true,
"platform": "mindbody",
"tier": "new",
"isExistingClient": false,
"includedCredits": 0 }
Controllerts// apps/tasks-server/.../frontdesk-widget.controller.ts
@Post(':entityId/identify')
@Public()
async identify(@Param('entityId', ParseUUIDPipe) entityId: string,
@Body() body: WidgetIdentifyDto) {
if (!body?.phone && !body?.email) throw new BadRequestException('phone or email is required');
const result = await this.integratedPlatforms.resolveClientTier(entityId, { phone: body.phone, email: body.email });
// platformClientId + memberships stay server-side — every later endpoint re-resolves by phone.
return { supported: result.supported, platform: result.platform, tier: result.tier,
isExistingClient: result.isExistingClient, includedCredits: result.includedCredits ?? 0 };
}
Tier rule (mindbody.service.ts)ts// active membership → 'member'
// no membership, has appointment
// history (past or upcoming,
// checked live) → 'returning'
// no history — a client record
// alone (inquiry, gift card)
// does NOT forfeit intro → 'new'
// IsProspect is only the fallback
// prior when the live history
// lookup itself fails
Returns new on Hoboken (no memberships configured there). Real tier differences need a Deka site (Wexford / Green Tree).
"New · Intro" pricing is keyed off whether the client has zero appointment history — checked live via staffappointments, past or upcoming — not Mindbody's IsProspect flag. A client record alone (a phone inquiry, a gift-card purchase) still gets intro pricing; only booking an actual appointment forfeits it. IsProspect is used only as the fallback when the live appointment-history lookup itself fails.
Screen 2 · Service
pick a service — with category order and embedded add-ons
from $X when membership/intro/staff-tier can change the price; category order preserved from Mindbody Program prefix; each service carries hasAddons + addons.services.services (+ variations, base price, embedded add-ons). No platform call; instant. config also returns preferences.cardRequirement, overlaid from frontdesk.card.requirement in EAV — gate the card-capture step on preferences.cardRequirement !== 'never'./services endpoint.Request: confighttpGET /tasks/api/v1/frontdesk/widget/:entityId/config
Request: serviceshttpGET /tasks/api/v1/frontdesk/widget/:entityId/services?page=1&limit=20
Responsejson{ "services": [
{ "id": "uuid", "name": "Classic Full Set",
"price": { "value": 127.99 },
"hasAddons": true,
"addons": [
{ "id": "uuid", "name": "Hydrating Eye Mask",
"priceCents": 1500, "durationMinutes": 10 }
],
"categories": [ { "id": "uuid", "name": "Full Sets", "orderIndex": 1 } ] }
],
"hasMore": true }
/config and /services now embed per-service addons and hasAddons. The next screen is Add-ons when hasAddons: true; otherwise the widget skips directly to Staff. Mindbody Program names like 1. Full Sets set order_index, and the display name strips the numeric prefix.
Screen 3 · Add-ons
optional extras — immediately after service, skipped when empty
hasAddons is false.addons array already returned on /config and /services. No separate widget add-ons fetch.The add-ons screen is driven entirely by the selected service payload. Master Lash Artist is not part of this list; it is a staff surcharge returned later on /staff.
Service payloadjson{ "id": "uuid",
"name": "Classic Full Set",
"hasAddons": true,
"addons": [
{ "id": "uuid", "name": "Hydrating Eye Mask",
"priceCents": 1500, "durationMinutes": 10 }
] }
Screen branchtsif (service.hasAddons) {
showAddons(service.addons);
} else {
goToStaff();
}
/config and /services both embed addons and hasAddons; the frontend does the skip logic from that payload.
Screen 4 · Staff
pick a provider — Master Lash Artist is a staff surcharge
+$10 · upgrade (fills) or +$20 · upgrade (full sets).staff.members + staff-service eligibility + staff surcharge resolution. The surcharge is returned as staff[].surcharge and is not a service add-on.Master Lash Artist is a staff surcharge. It is not in /services[].addons, not shown on the add-ons screen, and not selectable independently. The surcharge appears only after a provider is known.
RequesthttpGET /tasks/api/v1/frontdesk/widget/:entityId/staff?serviceId=uuid
Responsejson{ "staff": [
{ "id": "uuid", "firstName": "Emily",
"surcharge": { "amountCents": 2000, "label": "Master Lash Artist" } },
{ "id": "uuid", "firstName": "Alex",
"surcharge": null }
] }
Typetsexport interface StaffSurcharge {
amountCents: number;
label?: string;
}
Emily (Master Lash Artist) → surcharge.amountCents: 2000 on Classic Full Set. Non-MLA staff → surcharge: null. This is intentionally separate from service add-ons.
/staff only ever returns genuine providers, never the full roster. With serviceId, it filters to staff with an active staff_services row for that service. Without serviceId — or when a service has no staff_services mapping at all — it degrades to staff with at least one active service assignment, never to every staff record.
Screen 5 · Slot
pick a time — and hold it
bookableitems, 30s cache); stored in scheduling.blocked_slots for the hold./slots, in YYYY-MM-DD format./availability, in YYYY-MM-DD format./hold.Request: availabilityhttpGET /tasks/api/v1/frontdesk/widget/:entityId/availability?serviceId=uuid&from=2026-06-19&days=14
Request: holdhttpPOST /tasks/api/v1/frontdesk/widget/:entityId/hold
Responsejson{ "holdId": "uuid",
"expiresAt": "2026-06-19T14:10:00.000Z",
"timezone": "America/New_York" }
availability, slots, and hold all returned correctly against Hoboken (hold → 201, 10-min TTL, tz America/New_York). No pricing change needed here.
Screen 6 · Price & promo
tier price from mirror + staff surcharge; promo verified, not deducted
mindbody.service_pricing_tiers (tier → priceCents + saleServiceId) + staff surcharge. verify-promo checks Mindbody /site/promocodes but does not alter the widget total.What /price returns — render-ready for the slash. The frontend stays dumb: strike originalCents, show finalCents, print reason. originalCents = standard tier from mirror; finalCents = tier-matched price + optional staff surcharge.
The price path: tier from /identify → look up service_pricing_tiers by (serviceMetaId, tier) → get priceCents + saleServiceId. Staff surcharge is resolved from the selected provider × service and returned as its own line. Both lookups are pure Postgres.
/verify-promo returns { valid, code, discountType, discountAmount, message } from Mindbody /site/promocodes. A valid code is sent later as promoCode in /book and stamped into the booking note. It is applied at checkout, not deducted in the widget.
/identify: new · returning · member./verify-promo: customer-entered promo code to validate against /site/promocodes.RequesthttpPOST /tasks/api/v1/frontdesk/widget/:entityId/price
Response — mirror path (no promo)jsonc{
"supported": true,
"tier": "member",
"tierLabel": "Member",
"originalCents": 18000, // standard tier (mirror) → strikethrough
"finalCents": 13500, // member tier from service_pricing_tiers
"reason": "Membership pricing",
"surcharge": {
"amountCents": 2000, // staff surcharge from /staff rule
"label": "Master Lash Artist"
},
"lines": [
{ "name": "Classic Full Set", "amountCents": 13500 },
{ "name": "Master Lash Artist", "amountCents": 2000 }
]
}
Request: verify-promohttpPOST /tasks/api/v1/frontdesk/widget/:entityId/verify-promo
Response: verify-promojson{ "valid": true,
"code": "WELCOME10",
"discountType": "percent",
"discountAmount": 10,
"message": "Promo code will be applied at checkout." }
Rendered lines (Deka Wexford · Emily · member)Classic Full Set $̶1̶8̶0̶.̶0̶0̶ $135.00 ← member tier from mirror
Master Lash Artist +$20.00 ← MLA surcharge
Membership pricing ← reason
Mirror path confirmed: Classic Full Set — New $119.99 / Returning $180 / Member $135. Emily (MLA) → +$20 on full sets, +$10 on refills. Non-MLA staff → no surcharge line. Promo verification is separate and does not change finalCents.
Screen 7 · Card on file
requirement gate → collect card if needed
frontdesk.card.requirement (EAV), the same value /config overlays into preferences.cardRequirement; card check via Mindbody (live, per client)./card-status returns the studio's requirement (always / first_time / never), whether it's required for this client, and whether the client already has a card on file — as a plain boolean. Email-only lookups return clientFound: false — no unfiltered platform search is performed.
Earlier drafts of this endpoint returned cardLastFour, cardType, and platformClientId. Those are gone: /card-status is unauthenticated, and "does this phone number have a Visa ending 1234" is phishing material on a route anyone can hit. The widget only needs hasCardOnFile to decide whether to show the capture form — see Security · public-widget hardening.
When a card is needed and not on file, the widget calls /collect-card. This mints a one-time PCI token and submit target. The widget renders PAN fields inline — no iframe — and POSTs the raw PAN directly to the PCI lambda. The card number never touches our servers. The lambda saves the card in Mindbody's vault (cardOnly mode, no booking) and redirects or callbacks to completeUrl.
The PCI lambda accepts a cardOnly flag that saves the card without creating a booking. The toContext whitelist bug that silently dropped the cardOnly flag was fixed in pci-stack PR #12 — the lambda now correctly routes vault-only saves.
email is omitted./identify; needed to resolve required under a first_time policy.Request: card-statushttpPOST /tasks/api/v1/frontdesk/widget/:entityId/card-status
Response: card required, on filejson{ "requirement": "always",
"required": true,
"supported": true,
"clientFound": true,
"hasCardOnFile": true }
Response: card required, not on filejson{ "requirement": "always",
"required": true,
"supported": true,
"clientFound": true,
"hasCardOnFile": false }
Response: card never requiredjson{ "requirement": "never",
"required": false,
"supported": true,
"clientFound": true,
"hasCardOnFile": false }
Request: collect-cardhttpPOST /tasks/api/v1/frontdesk/widget/:entityId/collect-card
Responsejson{ "supported": true,
"token": "abc123",
"submitUrl": "https://pci.zoca-tech.com/card",
"bookingRef": "uuid",
"completeUrl": "https://widget.zoca-tech.com/card-saved" }
Visa test card collected via PCI lambda. Card-on-file detection returns hasCardOnFile: true (Mindbody itself reported Visa ••1234 internally; the public response no longer forwards it). Requirement gated correctly from EAV frontdesk prefs. Inline PAN → PCI lambda and cardOnly vault save confirmed (no spurious booking created).
Screen 8 · Booking
confirm — booking only, with the pricing note
addAppointment. The pricing summary (tier, price, promo code, service add-ons, staff surcharge) is written to appointment Notes. Booking also marks the FrontDesk funnel as booked and cancels pending follow-ups./verify-promo; stamped into the booking note and applied at checkout.Requestjson{ "phone": "+15551234567",
"serviceId": "uuid",
"staffId": "uuid",
"holdId": "uuid",
"promoCode": "WELCOME10",
"note": "Tier: Member; Price: $135; Surcharge: $20; Promo: WELCOME10 (apply at checkout)" }
Booking notets// createMindbodyBooking gained an optional note → appointment Notes
const appointmentData: MindbodyAddAppointmentRequest = {
ClientId, LocationId, SessionTypeId, StaffId, StartDateTime, SendEmail: true,
...(note ? { Notes: note } : {}), // pricing summary + verified promo for the front desk
};
book is a Mindbody mutation, gated by assertMindbodyWriteAllowed() in development. The Mindbody API sandbox (site -99) is the only exemption — real appointments can be booked there in dev. All other sites (including Deka) stay read-only in development. It books normally in production.
On successful booking, the widget marks the FrontDesk funnel stage as booked. Pending lead follow-ups cancel automatically so the customer does not receive abandonment nudges after booking.
Cross-cutting · Lead funnel
lead → service selected → slot selected → booked
The widget creates or updates a lead as soon as contact information is available, then advances the funnel as the customer selects a service and slot. Stages are lead, service_selected, slot_selected, and booked.
lead, service_selected, slot_selected, or booked.service_selected and later.slot_selected and later.Request: leadjson{ "phone": "+15551234567",
"email": "jane@x.com",
"name": "Jane Doe" }
Request: funneljson{ "phone": "+15551234567",
"stage": "slot_selected",
"serviceId": "uuid",
"slot": "10:00 AM" }
Responsejson{ "success": true,
"stage": "slot_selected",
"followUpActive": true }
Cross-cutting · Dev write-guard
the "no Mindbody writes in dev" guarantee, behind every mutation
Write guardts// libs/mindbody/src/lib/mindbody.service.ts
private assertMindbodyWriteAllowed(operation: string): void {
if (this.configService.isDevelopment()) {
this.logger.warn(`<MINDBODY> BLOCKED write "${operation}" — disabled in development.`);
throw new HttpException(`Mindbody writes are disabled in development (blocked: ${operation}).`, HttpStatus.FORBIDDEN);
}
}
addClient · addCardToClient · addAppointment · cancelMindbodyBooking · rescheduleMindbodyBooking · cancelAppointmentById · rescheduleAppointmentById. Not gated: reads + promo verification (/site/promocodes, non-mutating). Exempted site: Mindbody API sandbox (site -99) — the write-guard is skipped for sandbox, so the full booking and card-collection flow can be tested in dev without touching any real studio.
Cross-cutting · The layering
Every new capability is added at each layer so the widget speaks only our types and the platform stays swappable:
Widget endpoint
IntegratedPlatformsService (facade)
MindbodyPlatformAdapter
MindbodyService
Testing it
three test surfaces: Deka Wexford (mirror + identify), Deka Green Tree (card-on-file), and Mindbody sandbox -99 (full write path)
Pricing & identify — Deka WexfordbashENTITY=2b21fbb0-7716-4af8-846b-0306a4922dae
BASE=http://localhost:8001/tasks/api/v1/frontdesk/widget/$ENTITY
# 1. Identify tier (no platformClientId in the response — public route)
curl -s -X POST "$BASE/identify" -d '{"phone":"+14125551234"}' -H 'Content-Type: application/json'
# → {"supported":true,"platform":"mindbody","tier":"member","isExistingClient":true,"includedCredits":1}
# 2. Browse services (embedded add-ons + hasAddons)
curl -s "$BASE/services?page=1&limit=20"
# → service.hasAddons: true; service.addons: [...]
# 3. Browse staff for service (staff surcharge) — never the full roster
curl -s "$BASE/staff?serviceId=<uuid>"
# → Emily → surcharge.amountCents: 2000; Alex → surcharge: null
# 4. Price (mirror path — instant)
curl -s -X POST "$BASE/price" \
-d '{"serviceId":"<uuid>","tier":"member","staffId":"<emily-uuid>"}' \
-H 'Content-Type: application/json'
# → finalCents: 13500 (member), surcharge.amountCents: 2000 (MLA)
# 5. Verify promo (validation only — not deducted in widget)
curl -s -X POST "$BASE/verify-promo" \
-d '{"promoCode":"WELCOME10"}' -H 'Content-Type: application/json'
# → {"valid":true,"code":"WELCOME10","discountType":"percent","discountAmount":10,...}
# 6. Write guard fires on a Deka site
curl -s -X POST "$BASE/client" -d '{"phone":"+14125551234"}' -H 'Content-Type: application/json'
# → "Mindbody writes are disabled in development (blocked: addClient)."
Card-on-file — Deka Green Tree (site 5748465)bashENTITY=<green-tree-entity-id>
BASE=http://localhost:8001/tasks/api/v1/frontdesk/widget/$ENTITY
# 1. Card status (requirement gate + on-file check — no card brand/last-four, public route)
curl -s -X POST "$BASE/card-status" \
-d '{"phone":"+14125551234"}' -H 'Content-Type: application/json'
# → {"requirement":"always","required":true,"supported":true,"clientFound":true,"hasCardOnFile":true}
# 2. Collect card (mints PCI token → inline form POSTs PAN directly to lambda)
curl -s -X POST "$BASE/collect-card" \
-d '{"phone":"+14125551234"}' -H 'Content-Type: application/json'
# → {"token":"...","submitUrl":"https://pci.zoca-tech.com/card","completeUrl":"..."}
# The widget posts PAN directly to submitUrl; no iframe, no card number through our API.
# The lambda vaults the card in Mindbody and redirects to completeUrl.
Full write path — Mindbody API sandbox (site -99)bash# Site -99 is the ONLY site exempted from the dev write-guard.
# Use it to test client creation and real appointment booking end-to-end in dev.
ENTITY=<entity-id-for-site--99>
BASE=http://localhost:8001/tasks/api/v1/frontdesk/widget/$ENTITY
# 1. Get availability
curl -s "$BASE/availability?serviceId=<uuid>&from=$(date +%Y-%m-%d)&days=14"
# 2. Hold a slot
curl -s -X POST "$BASE/hold" \
-d '{"date":"<date>","slot":"10:00 AM","serviceId":"<uuid>","sessionId":"<any-uuid>"}' \
-H 'Content-Type: application/json'
# → {"holdId":"uuid","expiresAt":"...","timezone":"..."}
# 3. Book (write allowed on -99 in dev)
curl -s -X POST "$BASE/book" \
-d '{"phone":"+15551234567","name":"Test User","date":"<date>","slot":"10:00 AM","serviceId":"<uuid>","holdId":"<holdId>","promoCode":"WELCOME10"}' \
-H 'Content-Type: application/json'
# → {"success":true,"appointmentId":"...","bookingStatus":"BOOKED"}
# Follow-ups for the matching lead/funnel cancel automatically on booking.
What's left
- Frontend —
from $Xfloor price (Screen 2), add-ons skip logic (Screen 3), staff surcharge callout (Screen 4), slash + promo-verified-at-checkout field (Screen 6), card-on-file gate (Screen 7). - Booking session (Redis) threading tier/credits/card status/promo status across screens.
- Operational sync — nightly
sync-catalogcron so category ordering, add-ons, prices, and staff surcharges do not require manual re-sync.
Built on feat/mindbody-widget-pricing · served via the sonar Cloudflare tunnel · internal only.
What we reuse vs. what we add
keeping the build small
Reuse (already in the codebase)
Listing services, staff, availability; finding/creating a customer; creating, cancelling and rescheduling appointments — all already work in the Mindbody adapter.
Add (small, targeted)
Embedded service add-ons, a slot-hold, promo verification, lead/funnel tracking, the public booking API, the widget itself, and the availability accuracy fixes below.
Availability accuracy fixes
- Paging — today we only read the first page of times, so busy days get cut off. Read them all.
- Sorting — times currently sort as text, so "10:00 AM" can appear before "9:00 AM". Sort by actual time.
- End time — trust Mindbody's end time instead of recomputing it.
- "Any provider" — show one time once, not once per available staff member.
Security · public-widget hardening
the widget routes are public — no OTP/auth yet, deliberately deferred — so this is what stands in for it today
Every /frontdesk/widget/* route is @Public: no login, no session token, tied only to :entityId in the path. OTP-based phone verification (§ Open questions-adjacent, tracked as a follow-up) is the eventual fix; until it ships, four things do the work of keeping the surface honest — implemented in widget-rate-limit.guard.ts, frontdesk-widget.controller.ts, frontdesk-widget.service.ts, and main.ts.
/identify returns only { supported, platform, tier, isExistingClient, includedCredits }. /card-status returns only { requirement, required, supported, clientFound, hasCardOnFile }. platformClientId, membership objects, card brand, and last-four never leave the backend — every later endpoint (/price, /card-status, /collect-card, /book) re-resolves the client by phone server-side instead of trusting a client id round-tripped through the browser.
WidgetRateLimitGuard — per-IP sliding 60-second window, applied to every route on the controller. Sensitive routes (identify, verify-promo, lead, funnel, card-status, collect-card, client, book, hold) are capped at 10/min; catalogue reads (config, services, staff, availability, slots) at 120/min. A breach returns 429 Too Many Requests. It's in-memory — correct for a single tasks-server process; a Redis-backed store is the noted follow-up once the widget runs multi-instance.
CORS_ALLOWED_ORIGINS (comma-separated) locks tasks-server's CORS to real widget hosts. Unset — the historical behavior, and still the local-dev default — falls back to '*'. Deployers must set this env var to the actual embedding domains before a widget goes live publicly.
None of this replaces authentication. Without OTP, phone-as-identity is still spoofable — someone who knows a number can book (or probe card status) as that number. What the hardening buys: it's one-at-a-time, rate-limited, and leaks no card or client-id metadata to do it with. OTP + short-lived session tokens is the designed follow-up that closes this gap for real.
Earlier drafts of this doc (and of /identify / /card-status) showed platformClientId, memberships, cardLastFour, and cardType in the response body. Those fields are gone from both endpoints as of this hardening pass — see the request/response examples above and in the screen-by-screen reference for the current minimal shapes.
Security to fix
remaining, now that the public-route hardening above is in
- addressed The card collection path now uses one-time PCI tokens minted per session by
/collect-card. The inline widget form POSTs the PAN directly to the PCI lambda using this token — no iframe, no fixed API key, and no card number through our API. - addressed Public endpoints now carry per-IP rate limiting (10/min sensitive, 120/min general) and a
CORS_ALLOWED_ORIGINSallow-list — see Security hardening above. - addressed
/identifyand/card-statusno longer leakplatformClientIdor card metadata. - Move the hard-coded Mindbody credentials in our code into environment settings.
- Give card-collection tokens an expiry (today they're one-use, but the expiry policy should be explicit).
- Move the rate-limit store to Redis once tasks-server runs multi-instance (in-memory per-process today).
- OTP-based phone verification + short-lived session tokens — the real fix for phone-as-identity spoofing; everything above is a stopgap until this ships.
Open questions for Mindbody
things to confirm before the later phases
Does their API let us list and attach appointment add-ons? (Needed for v1's add-on step.)
Does "Mindbody Payments" let the card be tokenized in the browser, so we never see it? (Later phase.)
Get the separate webhooks key so we can keep availability fresh without constant polling. (Later phase.)
The plan
order of work
CheckoutShoppingCart; capture cards via Connect token (PCI enclave fallback). v1 must leave the seams open for this.- First (nothing blocking): availability fixes → embedded service add-ons → staff surcharge → promo verification → lead funnel → slot hold → public booking API → the widget → wire the prototype to live data.
- Alongside: the security fixes (rotate the leaked key especially).
- Then: cards & deposits, once we confirm Mindbody's tokenization.
- Later: webhooks for live availability, and onboarding polish.
Mindbody documentation
source references — what each link covers
The main API our backend uses — services, staff, availability, clients, and booking.
Endpoint-by-endpoint reference, including GetBookableItems (availability) and AddAppointment (booking).
How a business activates a developer's API key — the "activation" consent step in our onboarding.
Rate limits, supported payment processors, and general "what's possible" answers.
How card processing works (Stripe-powered) and which processors are supported — relevant to the later card/deposit phase. The deeper Payments "Connect" tokenization API is documented at payments.mindbodyonline.com/Docs/Connect (requires a developer login).
Event notifications (bookings, staff, closures) for keeping availability fresh — the later freshness phase.
Changelog — useful for confirming behaviour like GetBookableItems returning duration-validated slots.
Account home: API keys, site activation, and OAuth client setup (consumer-level — not used here).
Internal design doc — plain-language draft, no secrets included. Based on the Mindbody Public API, our codebase (Mindbody adapter, frontdesk services), and the dekalash prototype. Chatbot lib and the old widget lib are out of scope.