Onboarding is the self-serve signup for a new studio. It has two halves:
  • 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.
Source: 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 in onboarding.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.
The client IP used for throttling is the x-client-ip header when present, otherwise req.ip. The saas proxy forwards the real client address in that header. What the service does:
  1. Rejects a phone whose national core is shorter than 6 digits.
  2. Increments two Redis throttle counters, one for the phone and one for the IP. Either over its limit rejects the request.
  3. Generates a random 6 digit code and stores it at perform:onboarding-otp:<core> for 5 minutes. A new request replaces the old code.
  4. In dev mode, logs the code and returns it.
  5. Otherwise sends the SmartSend WhatsApp template registercode in language he with the code as both the body parameter and the URL button parameter.
Response: 200.
In dev mode data also has devCode:
Errors:

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.
A wrong or expired code increments 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.
Errors:

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.
The email is lowercased. Throttling uses two counters: one per email and one per client IP. The per-email counter collapses aliases with 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.
Failure counting works like the phone lane: five wrong codes within 10 minutes delete the stored code. The token has the same format as the phone token with payload { "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.
Errors: 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.
The handler also reads the x-user-email request header and passes it to the service. What the service does, in order:
  1. Verifies phoneToken against the phone’s national core. A bad or expired token stops here.
  2. Loads the studio.
  3. If emailToken is present, loads the User row. When the user is not yet verified and the token matches the user’s email, it sets emailVerified: true. This step is best effort. A mismatch is logged and any error is caught, so it never fails the bootstrap.
  4. Merges branding into Studio.branding: logoUrl (or null), primaryColor, onPrimaryColor and appTheme: "dark". Other existing branding keys are kept.
  5. Merges the wizard answers and bootstrappedAt into Studio.settings.onboarding. Existing keys there survive, most importantly completedAt, so re-running bootstrap never puts a finished studio back into the wizard.
  6. In one transaction, updates the studio and the caller’s Coach row (name, firstName, lastName, phone). The name is split on whitespace: first word, then the rest.
  7. 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 Client has status ACTIVE, the coach’s name and phone, and the email from x-user-email when present.
  8. Copies the default form templates into the studio with copyDefaultFormsToStudio when the studio has no forms yet. See Forms.
  9. Runs seedDemoPrograms for that client. See below.
The client in step 7 is created with Prisma directly, not through the clients service. It gets no subscription, no invite and no plan limit check. Response: 200.
Errors:

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.
Plans are keyed by their length in months:
  • When the body repeats a months value, the last entry wins.
  • An existing product with durationUnit: "MONTHS" and the same durationValue gets its priceAgorot and active updated. 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.
Response: 200.
Errors: 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.
A row with a bad required field (firstName, phone) fails the whole request with VALIDATION. What the service does for each row, in order:
  1. 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.
  2. Plan limit already hit. Once one row was refused by the plan limit, every later row is refused with PLAN_LIMIT_TRAINEES without trying.
  3. Create. Calls clients.create from the clients service with the name, phone, optional email, no tags, optional endsOn, and a productId when months matches an active month-based product (the AutoFit transition product is excluded). The caller’s user id is passed as the actor.
The clients service is built without a trainee notifier, so the import cannot message trainees and does not send the onboarding form. Plan limits. 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.
For any other failure the row’s 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.