The internal lane is for calls from the web app’s server to the core API that are not made on behalf of a signed-in studio user. It provisions the two rows that connect better-auth identities to the domain database: a Studio for an organization and a Coach for a member. Mount: /v1/internal, router internalRouter in src/modules/internal/internal.routes.ts. Lane: internal. requireServiceAuth only. The caller signs the request with SERVICE_AUTH_SECRET and sends x-service-id, x-timestamp, x-nonce and x-signature. There is no user context, no req.auth and no role check. The signing steps are on Authentication.
These endpoints trust the ids in the body. Anyone holding SERVICE_AUTH_SECRET can create or relink studios and coaches. Never expose the secret to a browser or a mobile build, and never proxy these routes.
Responses use the standard envelope. This lane is under the global /v1 limiter, keyed by the calling server’s IP.

Endpoints

POST /v1/internal/studios

Creates or updates the studio for a better-auth organization. The call is idempotent on externalOrgId. Auth: service signature.
string
required
The better-auth organization id.
string
required
The studio name.
string
required
The studio slug. Unique across studios.
string
At least 2 characters, for example he. The model default is he.
string
An IANA zone. The model default is Asia/Jerusalem.
What provisionStudio does, through studios.upsertByExternalOrgId:
  1. If a studio with this externalOrgId exists, its name is updated, plus locale and timezone when sent. The slug is not changed.
  2. Otherwise, if a studio with this slug exists and has no externalOrgId yet, it is linked to the organization and its name updated.
  3. Otherwise, if a studio with this slug exists and already belongs to another organization, that studio is returned unchanged. The caller receives a studio whose externalOrgId is not the one it sent.
  4. Otherwise a new studio is created with the default content categories for its locale.
When no studio existed for the externalOrgId before the call, the default form templates are copied into the studio (copyDefaultFormsToStudio). That covers a new studio and a studio that was just linked by slug. It also runs in case 3, where the returned studio belongs to another organization, so that studio receives a second copy of the default forms. A failure to copy the forms is logged and does not fail the request. Existing studios are never re-seeded, so later changes to the defaults do not disturb them. Response: 200. The Studio row.
The status is 200 for a create as well. Errors:
Studios are also created without this endpoint. On the web gateway lane, webUserContext creates or links the studio on the first signed request that carries a new x-studio-id, using the x-studio-name and x-studio-slug headers. No caller of POST /v1/internal/studios was found in the three repos, so this endpoint may only be used by scripts or be a leftover of the earlier split deployment.

POST /v1/internal/coaches

Creates the coach row for a studio member, or updates its role. Auth: service signature.
string
required
The internal Studio.id the coach belongs to.
string
required
The better-auth user id.
string
required
Display name. Used only when the row is created.
string
required
A valid email. Used only when the row is created.
string
default:"SUB_COACH"
HEAD_COACH or SUB_COACH.
The row is upserted on the unique pair of studioId and externalUserId. A new row gets all five values. An existing row only has its role updated. Name and email are left as they are. The caller is provisionCoach in backend/packages/auth/src/lib/provision-coach.ts. better-auth hooks call it after an invitation is accepted and after a member’s role changes, mapping owner and admin to HEAD_COACH and everything else to SUB_COACH. It logs a failure and never blocks the auth flow. Response: 200. The Coach row.
The row carries further Coach columns such as phone, permissions and lastActiveAt. They are omitted here. Errors:
provisionCoach sends the better-auth organization id as studioId, while this endpoint uses the value as the internal Studio.id foreign key. Unless the two ids are equal for a studio, the upsert fails with the 409 above and the helper only logs it. Owners are not affected, because webUserContext provisions the owner’s coach row itself. Check this path before relying on it for invited members.

Admin mounts on the same lane

Two more routers are mounted with requireServiceAuth only. They hold platform-wide defaults, not studio data, and are called by the web app’s admin area. Because there is no user context on this lane, the API cannot tell which admin made a change. The web app must check that the signed-in user is a platform admin before it calls these.