- A public half that verifies the coach’s phone over WhatsApp and their email with a code, before any account exists. Each verification returns a short-lived signed token.
- A web half that runs right after the better-auth signup sequence. It takes the tokens, brands the studio, creates the coach’s own trainee record with sample programs, saves subscription plans and imports trainees.
backend/apps/core-api/src/modules/onboarding/ (onboarding.routes.ts, onboarding.service.ts, onboarding.schema.ts, seed-demo.ts). This module has no controller or repository file. Handlers are inline in the routes file and the service calls Prisma directly.
Mounting and auth
/v1/public/onboarding is registered before the general /v1/public router in modules/index.ts.
Web endpoints call requireWebAuth, which throws UNAUTHORIZED (401) with missing web user context when req.auth lacks studioId or userId. There is no requireRole guard, so any role on the web lane can call them. See API overview for the web lane. The web lane’s webUserContext creates the Studio row and, for an owner, the Coach row before these handlers run.
Environment variables
Verification flow
Shared constants inonboarding.service.ts:
Phones are compared by their national core (
nationalCore in modules/trainee/trainee.service.ts): digits only, a leading 972 or 234 removed, then leading zeros removed.
The four public handlers validate with
safeParse and answer a failed validation themselves with status 400 and the body { "ok": false, "code": "VALIDATION" }. There is no message, details or requestId. Everywhere else in the API a validation failure is 422 with the full error envelope.Public endpoints
POST /v1/public/onboarding/request-otp
Sends a 6 digit verification code to a phone over WhatsApp.
Auth: none.
string
required
Trimmed, 7 to 30 characters.
x-client-ip header when present, otherwise req.ip. The saas proxy forwards the real client address in that header.
What the service does:
- Rejects a phone whose national core is shorter than 6 digits.
- Increments two Redis throttle counters, one for the phone and one for the IP. Either over its limit rejects the request.
- Generates a random 6 digit code and stores it at
perform:onboarding-otp:<core>for 5 minutes. A new request replaces the old code. - In dev mode, logs the code and returns it.
- Otherwise sends the SmartSend WhatsApp template
registercodein languagehewith the code as both the body parameter and the URL button parameter.
200.
data also has devCode:
POST /v1/public/onboarding/verify-otp
Checks the code and returns a signed phone token.
Auth: none.
string
required
The same phone the code was requested for. Trimmed, 7 to 30 characters.
string
required
Trimmed, 4 to 8 digits.
perform:onboarding-otp-fails:<core>, which expires after 10 minutes. On the fifth failure the stored code is deleted and the caller must request a new one. A correct code deletes both the code and the failure counter.
The token is base64url(payload).signature. The payload is { "p": "<national core>", "exp": <unix seconds> } and the signature is an HMAC-SHA256 over onboarding:<body> with BETTER_AUTH_SECRET.
Response: 200.
POST /v1/public/onboarding/request-email-otp
Sends a 6 digit verification code by email.
Auth: none.
string
required
Trimmed, a valid email, at most 254 characters.
throttleEmailKey: anything after a + in the local part is dropped, and dots are removed for gmail.com and googlemail.com. The code itself is stored under the address as typed, at perform:onboarding-email-otp:<email> for 5 minutes.
The email is sent with sendEmail from @repo/mail, template emailOtp, locale he, with the code as otp.
Response: 200 with { "sent": true }, plus devCode in dev mode.
Errors:
POST /v1/public/onboarding/verify-email-otp
Checks the email code and returns a signed email token.
Auth: none.
string
required
The same email the code was requested for.
string
required
Trimmed, 4 to 8 digits.
{ "e": "<email>", "exp": <unix seconds> }, signed over onboarding-email:<body>. The different prefix means a phone token can never be used as an email token or the other way round.
Response: 200.
VALIDATION (400, bare response), BAD_REQUEST (400) invalid or expired code, RATE_LIMITED (429) too many attempts, request a new code.
Web endpoints
POST /v1/web/onboarding/bootstrap
Turns the freshly created studio into a usable one. Safe to call again after a browser retry.
Auth: web lane, any role.
string
required
The token from
verify-otp. At least 10 characters.string
The token from
verify-email-otp. Absent for Google signups, whose email is already verified.string
required
The verified phone. Trimmed, 7 to 30 characters. Its national core must match the token.
string
required
Trimmed, 1 to 120 characters.
string
Wizard answer. At most 60 characters.
string
Wizard answer. At most 60 characters.
string
Wizard answer. At most 60 characters.
object
required
Studio brand.
x-user-email request header and passes it to the service.
What the service does, in order:
- Verifies
phoneTokenagainst the phone’s national core. A bad or expired token stops here. - Loads the studio.
- If
emailTokenis present, loads theUserrow. When the user is not yet verified and the token matches the user’s email, it setsemailVerified: true. This step is best effort. A mismatch is logged and any error is caught, so it never fails the bootstrap. - Merges branding into
Studio.branding:logoUrl(ornull),primaryColor,onPrimaryColorandappTheme: "dark". Other existing branding keys are kept. - Merges the wizard answers and
bootstrappedAtintoStudio.settings.onboarding. Existing keys there survive, most importantlycompletedAt, so re-running bootstrap never puts a finished studio back into the wizard. - In one transaction, updates the studio and the caller’s
Coachrow (name,firstName,lastName,phone). The name is split on whitespace: first word, then the rest. - Creates the coach’s own trainee record, unless a non-deleted client in the studio already has a phone with the same national core. The new
Clienthas statusACTIVE, the coach’s name and phone, and the email fromx-user-emailwhen present. - Copies the default form templates into the studio with
copyDefaultFormsToStudiowhen the studio has no forms yet. See Forms. - Runs
seedDemoProgramsfor that client. See below.
200.
POST /v1/web/onboarding/plans
Saves the studio’s fixed-length subscription plans as Product rows.
Auth: web lane, any role.
object[]
required
1 to 8 plans.
- When the body repeats a
monthsvalue, the last entry wins. - An existing product with
durationUnit: "MONTHS"and the samedurationValuegets itspriceAgorotandactiveupdated. Its name is not changed. - Otherwise a product is created with a Hebrew name of the form “N months”.
- Products with no duration are ignored.
- The AutoFit transition product (matched by
isTransitionProduct) is never repriced or switched off by a 1 month plan here. - Existing products whose length is not in the body are left as they are.
200.
VALIDATION (422), UNAUTHORIZED (401).
POST /v1/web/onboarding/import-trainees
Creates trainees in bulk from spreadsheet rows.
Auth: web lane, any role.
object[]
required
1 to 500 rows.
firstName, phone) fails the whole request with VALIDATION.
What the service does for each row, in order:
- Duplicate phone. If the phone’s national core already belongs to a non-deleted client in the studio, or to an earlier row in the same request, the row fails with reason
duplicate phone. - Plan limit already hit. Once one row was refused by the plan limit, every later row is refused with
PLAN_LIMIT_TRAINEESwithout trying. - Create. Calls
clients.createfrom the clients service with the name, phone, optional email, no tags, optionalendsOn, and aproductIdwhenmonthsmatches an active month-based product (the AutoFit transition product is excluded). The caller’s user id is passed as the actor.
planExempt is true while the studio is still in the /start wizard (billingFor(ctx).limits.isOnboarding(studioId)). In that case rows are created with no plan check. After the wizard, each row goes through the clients service’s plan check. See Billing.
Response: 200. index is the zero-based position in rows. planLimit is present only when the plan refused a row.
reason is the thrown error’s message.
Errors: VALIDATION (422), UNAUTHORIZED (401). Row failures never fail the request.
Sample programs: seed-demo.ts
seedDemoPrograms(prisma, studioId, clientId) gives the coach’s own trainee record a training program and a nutrition program, so the trainee app has content on the first login. Bootstrap is its only caller.
It is idempotent per program type. If the client already has a TRAINING program it creates none, and the same for NUTRITION.
Training program. Two days, “strength A” and “strength B” (Hebrew labels), with five exercise picks each. Each pick is a Hebrew search hint. The function looks for a global exercise (ExerciseLibraryItem with studioId: null) whose name contains the hint and that is not already used in that day. Missing picks are skipped. When fewer than three exercises resolve for a day, it fills the day with global strength exercises in name order. If neither day resolves anything, no training program is created.
The content is schemaVersion: 2 with discipline: "strength", workoutsPerWeek: 3, the movement requirement off, and the tempo and RIR columns off. Each row has fixed reps, a rest of 01:30 and RIR 2.
Nutrition program. One day with three meals: breakfast, lunch and dinner, three food picks each. Foods are resolved the same way against global FoodLibraryItem rows. The quantity depends on the food’s unitType: units for unit foods, grams otherwise. A meal with no resolved food is dropped, and no program is created when no meal is left.
The content is schemaVersion: 1 with goal: "balance", autoAlts: true, and day targets of 1850 kcal, 140 g protein, 180 g carbs, 60 g fat and 3 litres of water.
Both programs are created with status ACTIVE, startsOn set to now, and Hebrew “sample program” names.
seed-demo.ts is not related to the sales demo studio. That is a separate feature, described in Demo activity.