Docs

System Design · Draft

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)

branch feat/win-mindbody · ref TRD-2026-003 · prototype: dekalash

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.

In plain terms

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

1 Customer browser Booking widget Embeddable JavaScript on the studio site
Our backend
2 Public edge Public booking API Business-scoped endpoints for the widget
3 Core logic Shared booking service One booking vocabulary per studio
4 Platform bridge Mindbody adapter Translates Zoca ids into platform calls
5 Booking platform Mindbody API Availability, clients, and appointment creation
Booking widget

A small piece of JavaScript a studio adds to their website. It shows services, times, and a booking form.

Public booking API

The new backend endpoints the widget talks to. Open to the public (no login), but tied to one specific studio.

Mindbody adapter

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

Site ID

Mindbody's ID for a specific business location (e.g. -99787). We need it to point our calls at the right studio.

API Key

A key Mindbody gives us as a developer. It identifies our app on every request. We already have one.

Activation

The studio's act of granting our app permission to access their Mindbody data. This is the "consent" step.

User token

A short-lived pass we get from Mindbody (per studio) that authorizes each call. Auto-renews behind the scenes.

Add-on

An optional extra attached to a service (e.g. a hydrating eye mask on a lash appointment).

Slot

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.

1 · Browser-assisted today

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

2 · Self-serve activation recommended

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.

3 · One-click activation link

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.

In plain terms

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.

1 · API Key

Identifies our app on every request (a fixed header). We already have this from Mindbody.

2 · Activation

The studio's one-time consent that grants our app access to their Site ID. This is the "yes, you may" step.

3 · User token

A per-studio bearer token we mint using our partner staff credentials. Sent with every call (with the Site ID). Expires and auto-renews.

In plain terms

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.

Decision

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.

1

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.

backend: load config + create/update lead

2

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.

backend: list services (already built)

3

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

4

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.

backend: list staff + staff surcharge

5

Pick a date & time

Open times for that service/provider, shown in the studio's timezone.

backend: fetch real availability from Mindbody needs fixes

6

Hold the slot

The "reserved for 10:00" timer, so two people can't grab the same time.

backend: place a temporary hold new

7

Enter details

Name and phone (with SMS consent). We match or create the customer in Mindbody.

backend: find-or-create customer (already built)

8

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.

backend: create the appointment + cancel follow-ups

9

Done

Confirmation screen + emailed details.

backend: send confirmation

Why v1 is simpler than it sounds

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.

Mirror-cache: sync once, serve from Postgres

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.

New · Intro

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.

Returning · Standard

Has booked before, no active membership. Pays the standard catalogue price — no slash.

Member

Active membership → member rate, and possibly included credits (below).

1

Look up the client (phone / email)

One live lookup: getClients + activeclientmemberships + appointment history.

2

Pick the tier

Active membership → Member. Else appointment history → Returning · Standard. Else → New · Intro.

3

Resolve the amount

service_pricing_tiers by (serviceMetaId, tier) → priceCents + saleServiceId. Instant Postgres read.

4

Add the staff surcharge

Master Lash Artist: +$10 fills / +$20 full sets, returned on /staff[].surcharge and stacked on the tier price.

Member benefits / included credits

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

StatePrice screen
Member, no credit$180.00 $135.00 · Membership pricing
Member, included credit covers it“Expected in studio: $0” (or the “apply in studio” fallback)
New · Intro$180.00 $119.99 · Introductory offer
Returning · Standard$180.00 — no slash
Service has add-onsAdd-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 validCode shown as verified · “applied at checkout” · widget total does not drop
Promo invalidInline “not a valid code” · price unchanged
Never block the booking
  • 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.
Two add-on axes — keep them distinct

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

DataWhereWhy
Service catalogue, categories, base priceZoca DB syncedStatic menu — first paint
Tier prices (standard / intro / member)Mirror mindbody.service_pricing_tiersFrozen at sync; instant read
Service add-onsZoca DB services.service_addons, embedded on /servicesStatic per service
Staff + surchargeZoca DB staff.members + surcharge ruleDeterministic per provider × service
Holds, lead funnel, tracker, prefsZoca DB our stateBooking lifecycle we manage
Client identity + tierMindbody live clients · memberships · appointment historyPer-client, changes over time
Open slotsMindbody live bookableitemsReal-time availability
Promo validityMindbody live /site/promocodesValidation only — never deducted
The bookingMindbody live addappointmentPuts 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

CallTierp50p95Blocks the user?
Services / staff (catalogue)Instant80 ms200 msNo — first paint
Config (cached prefs)Instant150 ms400 msNo
Hold (blocked slot)Instant120 ms350 msBrief — on slot select
Tier price · add-ons · staff surcharge (mirror)Instant60 ms150 msNo — one Postgres read
Client resolve + membershipLive500 ms1.2 sYes — after identify
Availability (bookableitems)Live600 ms1.5 sYes — calendar load
Promo verification (/site/promocodes)Live400 ms1.2 sNo — booking can continue
Slots (scraper-first)Heavy900 ms2.5 sYes — 30 s cache softens
Confirm (addAppointment)Live700 ms1.6 sYes — one tap, spinner
Design rule

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

StepBuilt already?
List services & settingsyes — embeds addons + hasAddons
Add-ons stepyes — from service payload, skipped when empty
List staffyes — includes surcharge for staff upgrades
Get available timesyes — but needs accuracy fixes
Hold a slotnew — small
Verify promoyes — validates against Mindbody promo codes
Lead funnelyes — lead → service selected → slot selected → booked
Find/create customeryes — reuse
Create the bookingyes — reuse

API reference & contracts

what we shipped, and the shape of every call

All endpoints at a glance

MethodPathWhat
POST/auth/mindbody/registerRegister site + connect (→ activation code or connected)
GET/auth/mindbody/activation-statusPoll-safe activation check
POST/relay/api/v1/integrated-platforms/:entityId/sync-catalogSync pricing mirror (service_meta, tiers, addons, staff links)
GET/frontdesk/widget/:entityId/configBootstrap: first page of services + categories + prefs
GET/frontdesk/widget/:entityId/servicesPaginated + searchable services; each service embeds addons + hasAddons
GET/frontdesk/widget/:entityId/staffProviders with staff surcharge (?serviceId= → only who does it)
POST/frontdesk/widget/:entityId/identifyResolve pricing tier from phone/email (live Mindbody lookup) — returns tier only, no client id
POST/frontdesk/widget/:entityId/priceTier price + staff surcharge from mirror; promo is not deducted here
POST/frontdesk/widget/:entityId/verify-promoValidate promo against Mindbody /site/promocodes; no widget-side deduction
GET/frontdesk/widget/:entityId/availabilityDates with openings (one ranged call) + next available
GET/frontdesk/widget/:entityId/slotsOpen times for a service on one date
POST/frontdesk/widget/:entityId/holdReserve a slot (~10 min) for a session
POST/frontdesk/widget/:entityId/leadCreate/update FrontDesk lead and start follow-up funnel
POST/frontdesk/widget/:entityId/funnelMark funnel milestone: service selected, slot selected, booked
POST/frontdesk/widget/:entityId/clientFind-or-create the platform customer
POST/frontdesk/widget/:entityId/bookCreate the appointment (idempotent via holdId); accepts promoCode
POST/frontdesk/widget/:entityId/card-statusRequirement gate + on-file check (yes/no only — no brand/last-four)
POST/frontdesk/widget/:entityId/collect-cardMint 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.

The dividing line

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.

Onboarding API

api-server · authenticated (studio dashboard). Links a studio's platform account to a Zoca entity and drives activation. Base: /auth.

Public widget API

tasks-server · @Public, business-scoped by :entityId. Powers the embeddable widget. Base: /tasks/api/v1/frontdesk/widget.

Source of truth

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

POST /auth/mindbody/register

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.

entityId uuid required
The Zoca entity id.
siteId string required
Mindbody site id.

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" }
GET /auth/mindbody/activation-status

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.

entityId uuid required
The Zoca entity id.
siteId string required
Mindbody site id.

RequesthttpGET /auth/mindbody/activation-status?entityId=uuid&siteId=28902
Response: pendingjson{ "result": "activation_pending" }
Response: connectedjson{ "result": "Mindbody connected successfully" }
One id to rule them

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

GET /frontdesk/widget/:entityId/config

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.

entityId uuid required
The Zoca entity id (path parameter - scopes the response to one studio).

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" } }
GET /frontdesk/widget/:entityId/services

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.

entityId uuid required
The Zoca entity id.
page number optional default: 1
Result page to return.
limit number optional default: 20
Page size, capped at 50.
search string optional
Optional service-name search.
categoryId uuid optional
Optional category filter.

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 }
GET /frontdesk/widget/:entityId/staff

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

entityId uuid required
The Zoca entity id.
serviceId uuid optional
Optional service filter; returns only providers who can perform that service. Omitted, or the service has no mapping, → all staff with at least one active service assignment.

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" } }
] }
Add-ons are embedded now

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

GET /frontdesk/widget/:entityId/availability

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.

entityId uuid required
The Zoca entity id.
serviceId uuid required
The Zoca service id to check.
staffId uuid optional
Optional Zoca staff id; omit for any staff.
from date optional
First date to search, in YYYY-MM-DD format.
days number optional default: 14
Number of calendar days to scan.

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": [] } }
GET /frontdesk/widget/:entityId/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.

entityId uuid required
The Zoca entity id.
serviceId uuid required
The Zoca service id.
date date required
The date to inspect, in YYYY-MM-DD format.
staffId uuid optional
Optional Zoca staff id; omit for any staff.

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 }
POST /frontdesk/widget/:entityId/hold

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.

entityId uuid required
The Zoca entity id.
date date required
The local appointment date.
slot string required
The selected display time, for example 10:00 AM.
serviceId uuid required
The Zoca service id.
staffId uuid optional
Optional Zoca staff id; omit for any staff.
sessionId uuid required
Stable widget-session id used to replace this user's prior hold.

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" }
POST /frontdesk/widget/:entityId/client

Find-or-create the platform customer by phone (optional — book resolves it too, but the widget can validate the number early / surface "welcome back").

entityId uuid required
The Zoca entity id.
phone string required
Customer phone number in E.164 format when possible.
name string optional
Customer display name.
email string optional
Customer email address.

Requestjson{ "phone": "+15551234567", "name": "Jane Doe", "email": "jane@x.com" }
Responsejson{ "success": true, "platformClientId": "100045", "message": "Client ready" }
POST /frontdesk/widget/:entityId/verify-promo

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.

entityId uuid required
The Zoca entity id.
promoCode string required
The customer-entered promo code.

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." }
POST /frontdesk/widget/:entityId/book

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.

entityId uuid required
The Zoca entity id.
phone string required
Customer phone number.
name string required
Customer name.
email string optional
Customer email address.
date date required
The local appointment date.
slot string required
The selected display time, for example 10:00 AM.
serviceId uuid required
The Zoca service id.
staffId uuid optional
Optional Zoca staff id; omit for any staff.
holdId uuid optional
Hold id returned by /hold; used for idempotent booking.
promoCode string optional
Valid promo code from /verify-promo. Stamped into the booking note and applied at checkout; not deducted in the widget.
note string optional
Optional note written to the appointment.

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" }
POST /frontdesk/widget/:entityId/lead
POST /frontdesk/widget/:entityId/funnel

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.

entityId uuid required
The Zoca entity id.
phone string optional
Customer phone number; required when email is omitted.
email string optional
Customer email address.
stage string optional
For /funnel: lead, service_selected, slot_selected, or booked.
serviceId uuid optional
Selected service, when stage is service_selected or later.
slot string optional
Selected slot, when stage is 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

POST /frontdesk/widget/:entityId/card-status

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.

Deliberately minimal — public route

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.

entityId uuid required
The Zoca entity id.
phone string optional
Customer phone. Required when email is omitted.
email string optional
Customer email address.
tier string optional
Client tier from /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 }
POST /frontdesk/widget/:entityId/collect-card

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.

entityId uuid required
The Zoca entity id.
phone string optional
Customer phone. Required when email is omitted.
email string optional
Customer email address.

Responsejson{ "supported": true,
  "token": "abc123",
  "submitUrl": "https://pci.zoca-tech.com/card",
  "bookingRef": "uuid",
  "completeUrl": "https://widget.zoca-tech.com/card-saved" }
Verified live — Deka Green Tree (site 5748465)

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.

Connect by activation

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.

Token model

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.

Catalogue sync

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.

Catalog pricing sync

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

Location binding

Single-location sites are auto-bound (product_entities productId 13); multi-location sites use the picker. Booking resolves the physical location from this bind.

Merchant capability

At connect we record whether the site can charge and which cards it accepts — the seam for Phase-2 deposits / no-show protection.

Services at scale

Bookable items and session types are fetched with pagination + caching, and grouped by category, so large menus stay fast.

Proven end-to-end (Deka Wexford & Green Tree)

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

POST /relay/api/v1/integrated-platforms/:entityId/sync-catalog

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.

mindbody.service_meta

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.

mindbody.service_pricing_tiers

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.

services.addons

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.

services.service_addons

Add-on ↔ service link. linkSource = 'auto' when synced from Mindbody's SubcategoryId match; 'manual' for ops-curated links.

services.staff_addons

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.

Category order

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.

services.addons_logs

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.

isBookableOnline derivation

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.

Decoupling — platform-agnostic tables

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.

Mirror goes stale on price edits — re-run sync until the cron exists

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

Mindbody's official API pick

The proper endpoint that returns real open times, already checked against the service length. Needs two accuracy fixes (paging + sorting).

Scraping Mindbody's page

What we do now. Fine for the AI agent, but too fragile to power a customer-facing website.

Build availability ourselves

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

Shipped — verified live on Deka Green Tree (site 5748465)

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:

1

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.

POST /card-status — requirement from frontdesk prefs (EAV); card status from Mindbody (live)

2

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.

POST /collect-card — mint one-time PCI token; the raw PAN never touches our servers

3

Book

The booking proceeds normally. The card is already vaulted; the front desk sees it at in-person checkout.

POST /book — the hold id and card are now in place

In plain terms

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.

cardOnly mode — pci-stack PR #12

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

Status — committed requirement

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:

No-show fee

Card is already on file (stored at booking) → just charge it later with CheckoutShoppingCart. No new card capture. This is the easy one.

Deposit at booking

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

In plain terms

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.

Caveats before building

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

Safety guarantee (dev)

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.

These routes are public — no OTP yet

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.

1 Customer browser Booking widget Embeddable JavaScript on the studio site
Our backend
2 Public edge Public booking API Business-scoped endpoints for the widget
3 Core logic Shared booking service One booking vocabulary per studio
4 Platform bridge Mindbody adapter Translates Zoca ids into platform calls
5 Booking platform Mindbody API Availability, clients, and appointment creation

File map

PieceFile
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 surfacinglibs/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 syncMindbody Program number prefix → order_index in service/category payloads
Migration (all new tables)migrations/0468_addons_and_mindbody_mirror.sql
Mindbody typeslibs/mindbody/src/lib/mindbody.model.ts
Facade types (StaffAddonSurcharge)libs/integrated-platforms/.../types/standardized.types.ts
Sync pipelinelibs/integrated-platforms/.../adapters/mindbody.adapter.ts
Sync repository (upserts)libs/integrated-platforms/.../repositories/platform-sync.repository.ts
Facadelibs/integrated-platforms/.../integrated-platforms.service.ts
Sync endpointapps/servers/relay-server/.../integrated-platforms.controller.ts
Widget endpointsapps/tasks-server/.../frontdesk/frontdesk-widget.controller.ts
Lead funnel endpointsapps/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.

POST /tasks/api/v1/frontdesk/widget/:entityId/identify

Shows
Phone + name (+ optional email); "Welcome back" when found.
Source
Mindbody (live) — getClients + activeclientmemberships + an appointment-history check.
Returns
{ supported, platform, tier: 'new' | 'returning' | 'member', isExistingClient, includedCredits } — no platformClientId, no membership objects.

Public route — minimal response by design

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.

entityId uuid required
The Zoca entity id for the studio.
phone string optional
Customer phone number. Required when email is omitted.
email string optional
Customer email address. Required when phone is omitted.

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
Status — built & tested live

Returns new on Hoboken (no memberships configured there). Real tier differences need a Deka site (Wexford / Green Tree).

Tier rule — appointment history, not IsProspect

"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

GET /tasks/api/v1/frontdesk/widget/:entityId/config
GET /tasks/api/v1/frontdesk/widget/:entityId/services

Shows
Catalogue list; from $X when membership/intro/staff-tier can change the price; category order preserved from Mindbody Program prefix; each service carries hasAddons + addons.
Source
Zoca DB — 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'.

entityId uuid required
The Zoca entity id for the studio.
page number optional default: 1
Service page for the paginated /services endpoint.
limit number optional default: 20
Page size for service browse.
search string optional
Optional service-name search.
categoryId uuid optional
Optional category filter.

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 }
Status — catalogue + embedded add-ons built

/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

GET /tasks/api/v1/frontdesk/widget/:entityId/config
GET /tasks/api/v1/frontdesk/widget/:entityId/services

Shows
Optional service add-ons for the selected service. This screen appears before staff and is skipped when hasAddons is false.
Source
Zoca DB — embedded 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.

hasAddons boolean required
When false, skip this screen and go straight to Staff.
addons array optional
Per-service selectable add-ons embedded in the service object.

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();
}
Status — built

/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

GET /tasks/api/v1/frontdesk/widget/:entityId/staff

Shows
Staff cards; MLA-designated providers flagged +$10 · upgrade (fills) or +$20 · upgrade (full sets).
Source
Zoca DB — 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.

entityId uuid required
The Zoca entity id for the studio.
serviceId uuid optional
Service filter; required for service-specific staff eligibility and surcharge amount.

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;
}
Status — built & validated live (Deka Wexford)

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 never shows non-providers

/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

GET /tasks/api/v1/frontdesk/widget/:entityId/availability
GET /tasks/api/v1/frontdesk/widget/:entityId/slots
POST /tasks/api/v1/frontdesk/widget/:entityId/hold

Shows
Open times for the date; reserve on select (10-min hold).
Source
Mindbody (live) for slots (scraper / bookableitems, 30s cache); stored in scheduling.blocked_slots for the hold.

entityId uuid required
The Zoca entity id for the studio.
serviceId uuid required
The selected Zoca service id.
date date optional
Single date for /slots, in YYYY-MM-DD format.
from date optional
Start date for /availability, in YYYY-MM-DD format.
days number optional default: 14
Availability scan window.
staffId uuid optional
Optional provider; omit for any staff.
sessionId uuid optional
Stable widget-session id required for /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" }
Status — built (pre-existing) & tested live

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

POST /tasks/api/v1/frontdesk/widget/:entityId/price
POST /tasks/api/v1/frontdesk/widget/:entityId/verify-promo

Shows
Itemized priced cart (slash), staff surcharge line, promo field with “applied at checkout” messaging, then card gate / confirm.
Source
Mirror — 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.

Promo is validation-only

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

entityId uuid required
The Zoca entity id for the studio.
serviceId uuid required
The selected Zoca service id.
tier string optional
Client tier from /identify: new · returning · member.
staffId uuid optional
Selected Zoca staff id; used for staff surcharge lookup.
promoCode string optional
For /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
Status — built & validated live (Deka Wexford)

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

POST /tasks/api/v1/frontdesk/widget/:entityId/card-status
POST /tasks/api/v1/frontdesk/widget/:entityId/collect-card

Shows
Card requirement banner ("Studio requires a card on file") and either "Card on file" or an in-page PCI card form.
Source
Zoca DB — requirement from 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.

Public route — no card metadata in the response

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.

cardOnly mode

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.

entityId uuid required
The Zoca entity id for the studio.
phone string optional
Customer phone number; used for client lookup. Required when email is omitted.
email string optional
Customer email address; used for client lookup.
tier string optional
Client tier from /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" }
Status — built & verified live (Deka Green Tree · site 5748465)

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

POST /tasks/api/v1/frontdesk/widget/:entityId/book

Shows
"You're booked." Pay in-salon.
Source
Mindbody (live) — 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.

entityId uuid required
The Zoca entity id for the studio.
serviceId uuid required
Selected Zoca service id.
staffId uuid optional
Selected Zoca staff id; omit for any staff.
holdId uuid optional
Hold id from Screen 5.
phone string required
Customer phone number.
promoCode string optional
Valid code from /verify-promo; stamped into the booking note and applied at checkout.
note string optional
Pricing summary for the front desk.

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
};
Status — note threaded ✅; write-guard applies except on sandbox -99

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.

Funnel side effect

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

POST /tasks/api/v1/frontdesk/widget/:entityId/lead
POST /tasks/api/v1/frontdesk/widget/:entityId/funnel

Shows
No customer-facing screen; this is the background follow-up state machine behind the widget.
Source
stored — FrontDesk lead/funnel tables. Booking success cancels pending follow-ups automatically.

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.

entityId uuid required
The Zoca entity id for the studio.
phone string optional
Customer phone number; required when email is omitted.
email string optional
Customer email address.
stage string optional
lead, service_selected, slot_selected, or booked.
serviceId uuid optional
Selected service for service_selected and later.
slot string optional
Selected slot for 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);
  }
}
Gated mutations (7)

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:

1

Widget endpoint

apps/tasks-server — @Public route, validates input

2

IntegratedPlatformsService (facade)

resolveAdapter; reports supported:false when a platform lacks the capability

3

MindbodyPlatformAdapter

initForEntity (auth) + translate our ids ↔ platform ids

4

MindbodyService

the raw Mindbody API + the write-guard

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

Backend shipped — remaining work is mostly frontend/session polish
  • Frontend — from $X floor 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-catalog cron 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

  1. Paging — today we only read the first page of times, so busy days get cut off. Read them all.
  2. Sorting — times currently sort as text, so "10:00 AM" can appear before "9:00 AM". Sort by actual time.
  3. End time — trust Mindbody's end time instead of recomputing it.
  4. "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.

Minimal response shapes

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

Rate limiting

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

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.

Honest limitation

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.

Old response shapes — now removed

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_ORIGINS allow-list — see Security hardening above.
  • addressed /identify and /card-status no longer leak platformClientId or 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

Add-ons

Does their API let us list and attach appointment add-ons? (Needed for v1's add-on step.)

Card tokens

Does "Mindbody Payments" let the card be tokenized in the browser, so we never see it? (Later phase.)

Webhooks

Get the separate webhooks key so we can keep availability fresh without constant polling. (Later phase.)

The plan

order of work

Phase 1 — v1Self-serve activation onboarding · book without payment · service add-ons · staff surcharge · promo verification · lead funnel · slot hold · the widget. Plus the security fixes.
Phase 2No-show fees & deposits — a committed requirement. Charge via Public API CheckoutShoppingCart; capture cards via Connect token (PCI enclave fallback). v1 must leave the seams open for this.
Phase 3Keep availability fresh via webhooks; polish onboarding.

  1. 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.
  2. Alongside: the security fixes (rotate the leaked key especially).
  3. Then: cards & deposits, once we confirm Mindbody's tokenization.
  4. Later: webhooks for live availability, and onboarding polish.

Mindbody documentation

source references — what each link covers

[Public API v6](https://developers.mindbodyonline.com/PublicDocumentation/V6)

The main API our backend uses — services, staff, availability, clients, and booking.

[API reference (explorer)](https://developers.mindbodyonline.com/ui/documentation/public-api)

Endpoint-by-endpoint reference, including GetBookableItems (availability) and AddAppointment (booking).

[Access & activation](https://support.mindbodyonline.com/s/article/How-to-access-client-data-using-the-MINDBODY-API)

How a business activates a developer's API key — the "activation" consent step in our onboarding.

[API FAQ](https://support.mindbodyonline.com/s/article/API-FAQ)

Rate limits, supported payment processors, and general "what's possible" answers.

[Mindbody Payments / card processing](https://support.mindbodyonline.com/s/article/API-FAQ)

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

[Webhooks API](https://developers.mindbodyonline.com/WebhooksDocumentation)

Event notifications (bookings, staff, closures) for keeping availability fresh — the later freshness phase.

[API release notes](https://developers.mindbodyonline.com/Resources/ApiReleaseNotes)

Changelog — useful for confirming behaviour like GetBookableItems returning duration-validated slots.

[Developer portal](https://developers.mindbodyonline.com/)

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.