ADR-0017: Fizikal as the FreeFit/Move rails vendor
Status: Proposed (research complete, nothing observed live — a validation probe gates any build). Amended 2026-09-04 — see the addendum below. Date: 2026-08-08 Context owner: Saar Issue: FIT-65 (Move integration); adapter epic to be created. Related: FIT-258 (Arbox Bridge), ADR-0015.
Everything below about Fizikal is read from their OpenAPI spec, not observed. The guest key (
fizikalguest21) unlocks the Swagger document but every live endpoint returns401 Missing Api Key. ADR-0015’s Arbox findings, by contrast, were validated against a live box. Do not conflate the two confidence levels.
TL;DR — the decision
- For gyms that need Move/FreeFit, use Fizikal as the rails vendor, not Arbox. It is Move-approved, ₪349/mo including API access (vs Arbox ₪406 for the tier that exposes an API key), and its API is materially better engineered.
- Our posture is paying customer using a documented public API as documented — never “partner”. Fizikal is a direct competitor; every
_Custom.<Partner>namespace in their spec belongs to an adjacent vendor, a distribution channel, or one of their own customers. Assume they will never build an endpoint for us, and design only against what exists today. - This likely makes the Arbox parity bridge unnecessary for Move-driven gyms: import once into Taikan (FIT-34, already shipped), drop Arbox, subscribe Fizikal as a rails-only tenant. ADR-0015’s bridge remains the answer for the different customer who wants a cautious dual-run migration.
- The schedule is authored twice. Fizikal’s API cannot create or update classes, so a Move-facing class grid must be maintained by hand in Fizikal alongside Taikan. This is a permanent, accepted cost of this path.
- The capacity mechanism is not yet decided. Two designs are viable on paper — shared pool (mirror bookings) and allocation (partition seats). They differ in build size and in wasted seats. The choice is deferred to the probe (Open Questions Q1–Q4), because the deciding facts are unobserved.
- No payments, no member provisioning, no billing ever flow through the rails vendor. The rails tenant holds a schedule and seat counts; Taikan holds the business.
Context
The two goals, and why they split
FIT-258 was built around one bridge serving two purposes: (a) protected migration off Arbox, and (b) Move/FreeFit visibility with Arbox as a sanctioned middleman. Goal (b) drove the harder constraints because Move gates its booking integration to approved gym-management systems and will not onboard Taikan directly.
Fizikal changes the calculus for goal (b) only:
- They hold a meaningful number of Israeli gyms.
- ₪349/mo including API access (confirmed with their rep) versus Arbox, whose API key is gated to the Standard ₪406 tier while Move itself only needs Basic ₪157. The Arbox path therefore forces either ₪249/mo of dead spend or the staff-session workaround (ADR-0015’s 2026-07-29 addendum) with its ToS exposure.
- They publish a real, versioned, documented API.
Goal (a) is unaffected: Fizikal cannot serve migration parity at all (see Constraints).
What the Fizikal API actually is
https://api.fizikal.co.il, External_V1 = 60 endpoints (46 GET, 13 POST, 1 PATCH). The published summary doc lists only GETs and is misleading. Multi-tenant addressing is OrganizationId / CompanyId / BranchId on nearly every call.
Genuinely better than Arbox v3:
- Typed responses (real ints) instead of Arbox’s
string|nulleverywhere. - A clean envelope:
{success, statusCode, message, exception:{message, details, entity, validationErrors[{field,state,error}], logId}, data}— structured field-level validation errors are a real gift for an issues inbox. - Delta feeds (
FromUpdateDate/ToUpdateDate) oncustomers/searchandpurchases/search. Arbox has none. - A check-in write (
POST customer/entry). Arbox has none, which ADR-0015 had to scope attendance out for. - Waitlist writes (
waitinglist/add/remove). Arbox has none. - Rich purchase reads:
entriesLeft,isRecurringBilling,isFreezed, contract dates.
Relevant writes: classes/registration/add, classes/registration/remove, classes/waitinglist/add|remove, customer/entry, leads/Add, customer/messages/addPush, qr/create|checkstatus, questionnaires/*, tasks/add|update.
Relevant reads: classes/registration/search (per-class roster), classes/schedule/guestView|customerView (capacity), customers/search, customer/personaldetails, customer/purchases, entrances/search, purchases/search, pricelist/*.
Hard constraints (these do not have workarounds)
- No customer create or update. The only person-creating endpoint is
leads/Add. A member who joins Taikan after setup cannot be created in Fizikal. - No purchase/membership create. No equivalent of ADR-0015’s ₪0 bridge membership; no entitlement provisioning.
- No class create or update. The Move-facing schedule must be authored in Fizikal, and its capacity cannot be adjusted programmatically after the fact.
- Their rep indicated the ADMIN_V1/V2 surfaces (whose specs are auth-gated and returned empty to us) probably do not add customer writes.
Consequence: Fizikal is an engage-and-read API (book, check in, capture leads, push), not a provisioning API. Migration parity as designed in ADR-0015 cannot be built on it.
The _Custom.<Partner> pattern, and why it is not an opportunity
Fizikal’s spec carries bespoke namespaces per integration partner: Move, UpGrade360, Howazit, Holmes, Journeez, Synergym, Technion, RubinsteinEstate, Credimatch, Voicenter, Gates/Tibbo, Proptech. Two contain exactly what we lack — Journeez.Customers.AddCustomer → {customerId} and UpGrade360.Webhooks.AddWebhook {url, httpMethod, headers, body, frequencyMinutes}.
It is tempting to read that as “they build endpoints on request, so they could build ours.” They could, but they will not. Every name on that list is an adjacent service vendor (payments, telephony, turnstiles, CX, marketing), a distribution channel that brings them demand (Move), or one of their own gym customers. Not one is a rival gym-management platform. Taikan is precisely the product that removes the gym from Fizikal; asking them to provision our integration is asking them to fund their own displacement. This ADR assumes zero cooperation.
The pattern is still informative in two ways: Move integrates with each vendor bespokely (which explains why the FIT-269 Q6 direct-slot ask to Movement Group is custom engineering on their side, not a config toggle), and Fizikal’s Move surface tells us how aggregator traffic is modelled — RegistrationAddMoveRequest {classId, classDate, moveSourceId, customer} carries CustomerMove {identityCard, phone, firstName, lastName, email, genderId, dateOfBirth} inline, so Move users are upserted by Fizikal on the fly and keyed on national ID.
Decision
D1. Fizikal is the rails vendor; the rails tenant is dumb on purpose
A gym that needs Move runs Taikan for everything and keeps a Fizikal rails tenant holding only: a Move-facing class grid, seat counts, and whatever member records exist there. Taikan remains the system of record for members, memberships, payments, attendance and the real roster.
For Erez specifically the sequence is: one-time Arbox import into Taikan (shipped) → cancel Arbox → Fizikal ₪349 rails tenant → adapter. No parity bridge, no double-billing guard, no ₪0 bridge membership, no echo loops, no session-transport ToS gamble.
D2. Posture: customer, not partner
Credentials are the gym’s own, on the gym’s own paid subscription, calling documented public endpoints within their intended use. This is materially stronger than ADR-0015’s Arbox staff-session fallback, which drives an undocumented private API specifically to avoid a paid feature gate. If Fizikal ever objects, we lose a vendor, not a customer’s data.
D3. Capacity: two candidate mechanisms, decision deferred to the probe
One physical class, two systems selling seats. Safety requires one of:
Option A — shared pool (mirror bookings). Every Taikan booking is mirrored into Fizikal via registration/add, decrementing the count Move sees. Viable because registration/add exists and its body (customerId, classId, classDate, optional classSeatId) carries no entitlement id. The catch: it needs a customerId, and Taikan-native members have none. Two ways around that:
- Periodic member top-up — bulk-import new members into Fizikal through their admin UI monthly. Manual, bounded, no API needed.
- Placeholder seat pool — create N generic customers once (“Taikan Seat 01…20”) and mirror each Taikan booking as an occupation of an unused placeholder. The seat is consumed and the count is correct; the Fizikal roster shows meaningless names, which costs nothing in a rails-only tenant where nobody reads it. This makes the outbound mirror far lighter than ADR-0015’s: no member creation, no membership assignment, no identity mapping — just “occupy seat N” / “release seat N”. Hinges on Q1 (whether a customer with no active purchase may be registered).
Option B — allocation (partition seats). The Fizikal class is authored with a deliberately capped maxParticipants (say 5 of 20). Move can only ever sell its allocation; Taikan keeps the rest. No outbound writes at all — inbound roster sync only. Hinges on Q4 (whether Move respects the cap).
| A: shared pool | B: allocation | |
|---|---|---|
| Wasted seats | none | yes, and static — no class-update endpoint, so unsold Move seats cannot be handed back to members |
| Build size | outbox + mirror + reconciliation (lighter with placeholders, but real) | inbound poller only, perhaps a tenth of A |
| Ongoing ops | member top-up or placeholder pool upkeep | set the cap per class at authoring time |
| Fails when | a mirrored booking does not decrement what Move sees (Q2) | Move ignores maxParticipants (Q4) |
| Failure mode | overbooking | lost revenue on dead seats |
Neither is chosen here. Note the asymmetry that makes this worth testing rather than assuming: on Arbox we observed Move reading live shared capacity, which is the answer that breaks Option B. Arbox survives that because it can provision members and mirror bookings; Fizikal cannot provision, so if Q4 also fails there, Option A with placeholders is the only remaining design.
D4. Inbound is the irreducible core
Whichever capacity mechanism wins, the adapter must poll classes/registration/search over a forward window and surface Move-originated bookings in Taikan so the coach’s roster is real. Notes:
- That endpoint takes only a class-date window (
FromDate/ToDate) — it is not a delta feed; theFromUpdateDateadvantage applies tocustomers/purchases, not to rosters. Roster polling is a window scan exactly like Arbox’s. - Roster rows carry
customerId, name,topMobilePhone,isArrived,isTrialTraining, seat — but no email, no national ID, and no aggregator discriminator (Arbox haduser_role: 'aggregatorMember'). Matching a Move booker to a Taikan member is therefore phone-only. In a rails-only tenant the missing discriminator is moot: every Fizikal booking is by definition aggregator traffic. - The roster payload has no capacity fields;
maxParticipants/totalParticipantslive onschedule/guestView, which requires an undocumentedAppTypeId(Q5). - Move attendees are mostly not Taikan members. They should be ingested as external attendees occupying a seat, not as members — a Taikan-side model that does not exist yet and is vendor-neutral to build.
D5. What never crosses the boundary
No payments, no billing, no member provisioning, no workout or community data. The rails tenant is a seat ledger with a schedule attached.
Consequences
Positive
- Cheaper for the gym than the only clean Arbox path (₪349 vs ₪406) and removes the ToS exposure of the staff-session transport entirely.
- Dramatically smaller build than ADR-0015 — plausibly a tenth of it under Option B — and it deletes the migration bridge from the critical path for Move-driven customers.
- Better API ergonomics: typed responses, structured validation errors, a check-in write, waitlist writes.
- Failure modes are contained: the worst realistic outcome under Option B is a wasted seat, not a corrupted member record or a double charge.
Negative / costs
- The schedule is maintained twice, forever. Bounded for a stable weekly template, but never zero, and it is the friction the customer will feel daily.
- We remain dependent on a competitor’s goodwill, merely a better-behaved one. Fizikal can change or gate the API; the adapter must degrade to “Taikan works, Move visibility pauses”.
- Introducing a customer to Fizikal creates a relationship with a rival platform we did not previously have. At ₪349 rails-only with Taikan doing the daily work this is judged acceptable, but it is a real strategic cost.
- Identity matching is phone-only, which will produce ambiguous and missed matches; ambiguity must park for manual resolution rather than auto-link (the ADR-0015 rule carries over).
- Two vendors now exist in this problem space, so the Arbox-specific naming in the FIT-261 schema (
arbox_entity_map,arbox_sync_ops) should be generalized to provider-scoped tables before migration 0100 reaches production. Cheap now, expensive later. - Everything here is spec-derived. Arbox’s spec claimed
string|nullwhere reality returned ints, and documentedregistrationswhere reality returnedregistration_Details. Expect drift.
Alternatives considered
- Keep Arbox as the rails vendor (ADR-0015 as written). Rejected for Move-driven gyms: ₪406 for a legitimate API key, or ₪157 plus an undocumented private-API workaround whose entire purpose is dodging that gate. Retained for the cautious-migration customer, where the bridge’s provisioning ability is genuinely required.
- Ask Fizikal to build a Taikan partner namespace. Rejected — see the
_Customanalysis. They are a competitor; the ask is naive and the plan cannot depend on it. - Pursue a direct Move/FreeFit slot instead of any rails vendor. Still the correct endgame (FIT-269 Q6), but Move’s per-vendor bespoke integration model means this is custom engineering on their side with an unknown timeline. The rails tenant buys time and proves demand.
- Serve both goals from Fizikal. Impossible: no customer or membership provisioning.
- Do nothing; let the gym keep Arbox for Move indefinitely. The status quo, and the most expensive option for the customer (Arbox Basic ₪157 minimum, plus everything staying in a system they are trying to leave).
Open questions — the validation probe
Nothing is built until these are answered against a real, Move-connected Fizikal box. Estimated: one key plus an afternoon. This is the same bar FIT-269 set for Arbox, and it exists because that probe returned the answer that would have broken a design we had already committed to on paper.
| # | Question | Gates |
|---|---|---|
| Q1 | Can a customer with no active purchase/entitlement be registered to a class via registration/add? (Arbox required a membership_user_id; Fizikal’s body does not take one, but the server may still enforce it.) | The placeholder-seat pool, and therefore Option A |
| Q2 | Does a booking written via registration/add decrement the capacity Move sees? (The FIT-269 Q1 equivalent, re-asked for this vendor.) | Option A entirely |
| Q3 | Does registration/add enforce capacity and return a usable error when full? What is the error shape? | Both options |
| Q4 | Does Move respect a capped maxParticipants, or does it read live remaining capacity as Arbox does? | Option B entirely |
| Q5 | What is a valid AppTypeId? Without it schedule/guestView is uncallable, and it is the only source of per-class capacity. | Capacity reads |
| Q6 | Which auth scheme is live — the documented ?apikey= query parameter, or the spec’s X-API-key + X-Timestamp + X-Signature HMAC (algorithm undocumented)? | Any live call |
| Q7 | How does the gym get credited/paid for a Move visit, and must the entry be recorded in the rails system? If yes, customer/entry becomes mandatory — and note Arbox has no check-in write at all, which may be a latent revenue bug in ADR-0015. | Check-in push; possibly the vendor choice itself |
| Q8 | Do Move bookings appear in registration/search rosters, and can they be distinguished from ordinary Fizikal bookings on a mixed-use box? | Ingest rules (moot on a rails-only tenant) |
| Q9 | Runtime shape drift versus the spec, and pagination/rate-limit behavior. | Client and poller |
Q1, Q2 and Q4 are the decision-critical trio: they determine which capacity mechanism exists at all. If Q2 and Q4 both fail, Fizikal cannot safely share a class with Taikan and this ADR should be reconsidered.
Addendum 2026-09-04 — resolved OpenAPI read, and one fact observed live
The body above was written against a summary of the spec. Working from the resolved document (67 paths, 300 schemas) changes six things. Build plan: docs/plans/fizikal-freefit-two-way-sync.md. Probe tool: scripts/fizikal-external-v1-probe.mjs.
registration/removerequiresregistrationId, and the roster row does not carry it. Outbound bookings must persist the id Fizikal mints, or the seat can never be released. Conversely, a Move booking can be cancelled by us: roster →customerId→classes/upcoming/customerClasses→registrationId→registration/remove. D4 understated our reach here.- Q5 is not a blocker.
classDetails/guestViewtakes onlyId+Date— noAppTypeId— so per-occurrencemaxParticipants/totalParticipantsare readable today.AppTypeIdgates only the cheaper windowed read. - Occurrence identity is
classId+classDate, whereclassIdis the recurring template. The “schedule authored twice” cost in D-item 4 is therefore per weekly template, not per occurrence — materially smaller than stated. purchases/addexists (customerId+priceListItemId+salesmanId, all pre-existing). If Q1 fails, placeholder customers can be granted a hand-made ₪0 entitlement over the API. Q1 is no longer fatal to Option A.- Move’s own DTO carries both
maxParticipantsandtotalParticipants(_Custom.Move.ClassMove), andRegistrationAddMoveRequestupserts the customer inline keyed onidentityCardwith amoveSourceIdtag. The Move-facing surface exists but no path in our document references it — asking Fizikal support whether our tenant can use it is a customer question, not a partnership ask, and is posture-safe under D2. - Q6 answered, live, with a real key (2026-09-04). Their integrator issued a test club (
Org 1 / Company 2 / Branch 7, customer1962) and a key.x-API-keyalone is the live scheme: the key validates and the request then fails on IP with401 "Unauthorized External ClientIp <our ip>"— an error that names the offending address, which makes allowlist drift self-diagnosing.?apikey=returnsMissing Api Keyand is swagger-only. A second, signature-authenticated mode also exists and is enforced: a malformedX-Timestampreturns400 "Invalid X-Timestamp format. Expected yyyy-MM-ddTHH:mm:ss.fffZ in UTC"and a correct one returns401 "Invalid X-Signature". If that mode is not IP-bound it removes the Railway-only test constraint entirely — algorithm and secret are undocumented and have been asked for.AppTypeId=0is valid, and their own examples passclassDateas a date only.
Also confirmed: no webhooks are reachable (every webhook schema in the document is orphaned — zero paths reference _Custom.UpGrade360.Webhooks.* or _Custom.Credimatch.Webhook*), the roster has no delta filter and no pagination, and there is no idempotency key or seat hold on any write. The pipe is a poller, and the poll period is the double-booking race window.
Scope reality, and it re-sequences the build: the grant covers exactly four endpoints — customers/SearchActiveCustomers, classes/schedule/customerView, classes/registration/add, classes/registration/remove. classes/registration/search is not authorised, so the inbound roster half of D4 cannot be built until they open it. The outbound mirror can. Owner decisions on the back of this: no seats reserved for Move (B = 0, members always win) and no identity matching in v1 (Move bookers are external attendees shown as name + EXTERNAL). Consequence for D3: full-capacity mirroring, no buffer; Option B allocation is retained only as the fallback if a mirrored booking turns out not to move totalParticipants.
One ADR-0017 cost item is now free: arbox_entity_map/arbox_sync_ops were never built (no such tables in libs/db/src/lib/schema/, no such migration in libs/db/drizzle/ at 0122). Provider-scoped naming costs nothing today.
Probe result 2026-09-04 — Q1 and Q2 are GO, observed
Executed from @taikan/api on Railway (the only service with static outbound IPs; the scheduler’s egress is rejected). A real seat was booked and released on the test club: class 9074 @ 2026-09-08, customer 1962 → success: true, registrationId: 977328741, and totalParticipants moved 0 → 1 → 0, confirmed by independent reads.
- Q1 GO — no entitlement is required to register. The stub-seat pool works without
purchases/add. - Q2 GO in its decisive form — the mirrored booking moves
totalParticipants, which is the field Fizikal hands Move inClassMove. End-to-end Move proof still needs a Move-connected club. - Q3 partial — “already registered” (
HTTP 200,success:false,message:"ביטול") and “stale registrationId / already removed” (404 Entrance Not Found, soremoveis not idempotent) are known. Class-full is still unobserved: one test customer cannot fill a class. - Q5 closed —
AppTypeId=0; andAppTypeId=1silently returns zeroed capacity, so the value is load-bearing. - New constraint — age-limited classes reject on the customer’s date of birth (
תאריך לידה לא תקין). Stub seat customers need a DOB valid for every class they may occupy, or they fail only on age-gated classes. - New constraint —
schedule/customerViewrefuses anyFromDatebefore Sunday of the current week and any range over 7 days. There is no historical read, so reconciliation is forward-only.
Full evidence, error taxonomy and consequences: docs/plans/fizikal-freefit-two-way-sync.md.
Related
- ADR-0015 — Arbox parity mode; retains the cautious-migration use case, and its Move findings (live shared capacity, aggregator visibility, no check-in write) are the observed baseline this ADR compares against.
- FIT-65 (Move integration), FIT-269 (Arbox→Move propagation, GO), FIT-259 (Arbox v3 spike), FIT-34 (Arbox import, shipped).
- Fizikal spec:
https://api.fizikal.co.il/swagger/External_V1/swagger.json?apikey=…(the query parameter is required to see any paths). Local notes:/Users/saar/dev/fizikal-api.md.