The trainee app signs in with a phone number and a 6-digit code sent over WhatsApp. There are no passwords and no better-auth session. On success the API returns a self-signed trainee token that the app stores and sends on every later request. Mount: /v1/trainee, router traineeRouter in src/modules/trainee/trainee.routes.ts. The two OTP routes are public. switch-studio requires a trainee token. All three sit behind the global /v1 rate limiter (120 requests per 60 seconds per IP by default).

Flow

Phone matching

A trainee is a Client row with a phone. Matching is done on the national core of the number, computed by nationalCore in trainee.service.ts:
  1. Keep digits only.
  2. Drop a leading 972 or 234 country code.
  3. Drop leading zeros.
A core shorter than 6 digits is rejected. The repository first fetches candidates whose stored phone contains any variant of the number (phoneMatchVariants), limited to clients that are not deleted and whose studio is not archived, newest first. The service then keeps only candidates whose own national core is exactly equal. That second step stops a short number from signing in as the holder of a longer number that contains it. One phone can belong to trainees in several studios. The newest account signs in, and the response lists all of them in studios so the app can offer a picker and call switch-studio.

The trainee token

trainee.token.ts builds the token without a library:
Regular sessions never expire. There is no refresh endpoint and no server-side session table. A token stops working when:
  • the signature no longer verifies, for example after BETTER_AUTH_SECRET is rotated,
  • the studio is archived (studio no longer exists, checked on every request with a 30 second cache),
  • the client or studio is deleted, or the app is locked, on routes that use the access guard.
Signing out is client side: the app drops the token and calls DELETE /v1/trainee/push-token. See Profile and home.

Preview tokens

A coach can open the trainee app as one of their trainees from the web dashboard. POST /v1/web/clients/:id/preview-token (clients module, web gateway lane) returns { token, expiresInSeconds } with preview: true and a 15 minute expiry (PREVIEW_TTL_SECONDS = 900). A preview token behaves like the trainee with three differences:
  • Any request that is not a GET returns 403 FORBIDDEN with details.reason = "PREVIEW_READ_ONLY".
  • It never updates Client.timezone.
  • GET /v1/trainee/me and GET /v1/trainee/home do not stamp lastCheckInAt, so a coach looking at the app does not count as trainee activity.

App lock

Routes guarded by requireTraineeAppAccess return 403 FORBIDDEN with details.reason = "APP_LOCKED" when the trainee’s subscription is frozen (planFrozenOn set) or its coverage has ended, unless the coach set appAccessWhileFrozen to true. The sign-in routes and GET /v1/trainee/me are not guarded, so a locked trainee can still sign in. The client.appLocked flag in the sign-in and me responses tells the app to show the lock screen instead of calling guarded routes.

Endpoints

POST /v1/trainee/auth/request-otp

Sends a 6-digit code to the phone over WhatsApp. Auth: public.
string
required
At least 6 characters. Any formatting is accepted. Only the digits are used.
What the service does:
  1. Rejects a number whose national core is shorter than 6 digits.
  2. Looks up trainee accounts for the number. No match returns 404, so the endpoint does reveal whether a number is registered.
  3. Increments perform:trainee-otp-throttle:phone:<core> in Redis. The first hit sets a 600 second expiry. More than 3 sends in the window returns 429.
  4. Generates the code with crypto.randomInt(100000, 1000000) and stores it at perform:trainee-otp:<core> for 300 seconds. A new request overwrites the previous code.
  5. Sends the WhatsApp template registercode in Hebrew through SmartSend, with the code as both the body parameter and the URL button parameter. The sending organization is SMARTSEND_OTP_API_KEY, falling back to SMARTSEND_API_KEY, falling back to the studio’s own settings.smartsend.apiKey.
When TRAINEE_OTP_DEV_MODE is true, no WhatsApp message is sent. The code is logged and returned in the response as devCode. Never enable this in production. Response: 200.
In dev mode data is { "sent": true, "devCode": "482913" }. Errors:

POST /v1/trainee/auth/verify-otp

Exchanges the code for a trainee token. Auth: public.
string
required
The same number used to request the code. At least 6 characters.
string
required
4 to 8 characters. Codes issued by this API are 6 digits.
string
ios or android. Stored as Client.lastCheckInPlatform.
What the service does:
  1. Compares the code with the value stored in Redis. A missing or different value returns 400. The status is 400 and not 401 on purpose: the mobile client signs the user out on any 401, and a mistyped code must not do that.
  2. Loads the trainee accounts for the phone and picks the newest.
  3. Deletes the stored code so it cannot be used twice.
  4. Stamps lastCheckInAt and the platform, closes any open automatic INACTIVE inbox item for the trainee, and records a ClientAppDay row for today.
  5. Signs a token for the chosen account.
An app-store review account also exists. When TRAINEE_REVIEW_PHONE and TRAINEE_REVIEW_CODE are both set and the request matches both, the Redis check is skipped. The values are environment configuration and are not documented here. The service keeps no counter of wrong codes. A wrong code leaves the stored one in place until it expires, so the only brake on guessing is the global per-IP limiter. Response: 200.
Each entry in studios carries the full studio view. It is shortened here. The values of branding, mbpAnchors and mbpUnitLabel are examples. branding is the studio’s stored JSON passed through projectBranding, and the anchor numbers come from the studio settings.
string
active, paused (frozen), scheduled, waiting or none.
string | null
When coverage ends, including plans queued behind the current one.
number | null
Whole days until endsOn, only for active and paused.
object | null
The next scheduled plan as { planName, startedOn, endsOn }.
string | null
For a waiting subscription: intake_form, first_plan or first_workout.
string | null
The studio’s settings.contactWhatsapp, or the assigned coach’s phone.
boolean
false only when settings.calendarEnabled is explicitly false.
Errors:

POST /v1/trainee/auth/switch-studio

Issues a token for another studio the same phone number trains at, without a new code. The current token already proves the phone. Auth: trainee token. No app access guard, so it works for a locked account. Preview tokens are refused because the method is not GET.
string
required
The clientId of the target account, taken from the studios list.
string
ios or android.
The service loads the current client, lists the accounts for its phone with the same exact-core rule as sign-in, and requires the target to be one of them. It then stamps the target’s check-in and signs a new token. Response: 200, the same shape as verify-otp: token, client, studio and studios for the target account. Errors: