src/modules/index.ts has its own middleware and its own credential. This page walks through all of them. The lane table and mount map are on the API overview.
All secrets below are placeholders. Never commit real values.
Web gateway lane
Prefix:/v1/web/*. Middleware: requireServiceAuth (packages/security), then webUserContext (src/middleware/web-context.ts), then the web rate limiter.
The coach web app never lets the browser call the core API. Its server signs each request with the shared secret SERVICE_AUTH_SECRET and forwards the user it already authenticated with better-auth as plain headers. The headers are trusted only because the signature proves the caller is the web app.
Service signature
1
Hash the body
bodyHash = sha256Hex(rawBodyBytes). For a request with no body, hash the empty string. The server hashes req.rawBody, the exact bytes it received, so serialize JSON once and send that same string.2
Build the canonical string
Join five values with a newline: the upper-case method, the path, the timestamp, the nonce and the body hash. The path is
req.originalUrl on the server, so it includes /v1/web/... and the query string exactly as sent.3
Sign
signature = HMAC-SHA256(SERVICE_AUTH_SECRET, canonical) as lower-case hex.4
Send
Add the four headers and the user context headers below.
createServiceClient in backend/packages/security/src/index.ts and coreApiRequest in frontend/apps/saas/modules/shared/lib/core-api.ts.
The server checks, in this order: all four headers present, a known x-service-id, a timestamp within 300,000 ms of server time in either direction, a matching signature (compared with timingSafeEqual), and a nonce that has not been used. Nonces are stored in Redis under perform:nonce:<nonce> with SET NX and a TTL of 360 seconds.
User context headers
webUserContext does more than read headers:
- It resolves the studio by
externalOrgId. If none matches, it looks for a studio with the same slug and links it. If there is still none, it creates the studio with the default content categories. The result is cached in memory per organization id. - When the role is
OWNER, it upserts aCoachrow with roleHEAD_COACHfor that user, once per process. - It stamps
Coach.lastActiveAtat most once every 5 minutes per coach. - It sets
req.auth = { studioId, userId, role }, wherestudioIdis the internalStudio.id, not the organization id.
x-studio-id or x-user-id returns 401 UNAUTHORIZED with missing web user context.
Roles and coach permissions
Role checks inside web routers userequireRole(...roles) from src/middleware/require-role.ts. It returns 401 authentication required when req.auth is missing and 403 FORBIDDEN with insufficient role when the role is not listed.
Some routers also resolve a per-coach permission set with withCoachAccess or requireAssignedClient (src/middleware/coach-access.ts). The rules in coachAccessFrom:
OWNERalways has full access.- A
Coachrow withactive = falseis refused with 403this team member has been removed. - A caller with no
Coachrow keeps studio-wide access. - Otherwise the stored
permissionsblob decides. Whenpermissions.traineesis notall, the coach only reaches trainees assigned to them throughClient.coachAssignments, and any other client id returns 404client not found.
Internal and admin lane
Prefix:/v1/internal/*, /v1/admin/default-forms, /v1/admin/notification-configs. Middleware: requireServiceAuth only.
Same four signature headers as the web gateway, but no user context and no req.auth. These routes act on whatever ids the body carries, so they must only ever be called by the web app server. See Internal endpoints.
Trainee lane
Prefix:/v1/trainee/*. Middleware: authenticateTrainee and, on most routes, requireTraineeAppAccess (src/middleware/trainee-auth.ts).
The app signs in with a WhatsApp one-time code and receives a trainee token. The full flow is on Trainee sign-in.
The token is not a JWT and not a better-auth session.
trainee.token.ts builds it as base64url(JSON payload) + "." + base64url(HMAC-SHA256(payload, BETTER_AUTH_SECRET)). The payload holds clientId and studioId. Regular tokens carry no expiry. Preview tokens carry preview: true and an exp 15 minutes ahead.
The mobile app also sends X-Timestamp and X-Nonce on its requests. The trainee lane does not read them. Replay protection is only mounted in front of the bearer lane.
What authenticateTrainee checks
The studio check is cached in memory for 30 seconds per studio. Because tokens never expire, this check is how archiving a studio reaches sessions that already exist.
What requireTraineeAppAccess checks
This second guard is named access in trainee.routes.ts. It loads planFrozenOn, appAccessWhileFrozen, endsOn and the live subscriptions, cached for 30 seconds per client.
“Ended” uses
coverageEndsOn, so a plan queued behind the running one keeps the app open. A trainee with no end date and no freeze is never locked.
Not every trainee route uses access. Sign-in, GET /v1/trainee/me, DELETE /v1/trainee/me, GET /v1/trainee/app-release and the push token routes only use authenticateTrainee, so a locked trainee can still load the lock screen and delete their account. The smaller trainee routers (forms, uploads, calendar, content, shopping, foods, notification settings) also mount authenticateTrainee only. The forms service applies its own frozen-plan rule.
Partner and automation lanes
Prefix:/v1/partner/* and /v1/automation/*. Middleware: requirePartnerKey (src/modules/partner/partner-auth.ts).
Both lanes use a studio API key. A coach with role OWNER or HEAD_COACH creates it through POST /v1/web/studios/current/api-keys. The key is pf_live_ followed by 40 hex characters (20 random bytes). Only its SHA-256 hash is stored in StudioApiKey.keyHash, along with the first 15 characters as prefix for display. The plain key is returned once, in the create response.
req.partner = { studioId, apiKeyId }. Every query in both lanes is scoped to that studio. There are no scopes on a key: any valid key can call every endpoint in both lanes. lastUsedAt is updated at most once every 5 minutes per key.
The error body differs by lane.
/v1/partner uses the standard error envelope. /v1/automation uses { "success": false, "message": "..." }. See Partner API and Automation API.
The MCP endpoint accepts the same key and calls /v1/automation on the caller’s behalf.
Public lane
/v1/public/waitlist-leads and /v1/public/onboarding/* take no credential. They rely on rate limits and, for the onboarding OTP, on per-phone and per-IP throttles. See Public endpoints.
/v1/public/sign/:token is the public signing page for documents to sign. The 128-bit token in the path is the credential. Two callers reach it:
- A browser or script calling the API directly. It is counted by the global
/v1limiter under its own IP. - The web app’s proxy. It sends
x-service-id, so the router requires a valid service signature, and in return the request skips the global limiter and the proxy’sx-client-ipheader is believed as the visitor address.
x-service-id without a valid signature is refused with 401. The lane has its own per-link and per-IP counters, listed on Conventions.
Webhook lane
POST /v1/whatsapp/webhook/:studioId and POST /v1/agent/webhook are called by SmartSend. There is no signature and no bearer token. The caller appends ?token=... and the API compares it to SMARTSEND_WEBHOOK_SECRET with timingSafeEqual (verifyWebhookToken in packages/smartsend). A missing or wrong token, or an unset secret, returns 401 UNAUTHORIZED with invalid webhook token.
POST /api/webhooks/payments, handled by @repo/payments inside the Hono app, and verified by that package.
Bearer lane
Prefix:/v1/studios, /v1/clients, /v1/whatsapp. Middleware: replayProtection, then authenticate.
Replay protection requires x-nonce and x-timestamp. Missing headers return 400 BAD_REQUEST with missing x-nonce or x-timestamp header. A timestamp outside 300 seconds or a reused nonce returns 401.
authenticate reads Authorization: Bearer <session token> and resolves it through better-auth. With SESSION_VERIFY_INPROCESS set it calls auth.api.getSession in the same process. Otherwise it fetches BETTER_AUTH_URL/api/auth/get-session. The session’s activeOrganizationId becomes studioId and the member role maps owner to OWNER, admin to HEAD_COACH, and anything else to SUB_COACH.
better-auth sessions
Coach sign-in, organizations, passkeys and two-factor all go through better-auth at/api/auth/*. The web app keeps the session cookie and never sends it to /v1. See Auth endpoints.