A lane is a group of routes that share one way of proving who the caller is. The lane decides the guard and the principal. The module behind it does not care which lane it is on, as long as it gets the principal it expects. All /v1 lanes are registered in buildV1Router in apps/core-api/src/modules/index.ts. Three more surfaces are mounted directly in app.ts.

Summary

Registration order

The order in buildV1Router is deliberate:
  1. Webhooks.
  2. Service signed routes (/internal, /admin/default-forms, /admin/notification-configs).
  3. Trainee routers. The specific sub paths (/trainee/notification-configs, /trainee/forms, /trainee/uploads, and so on) are mounted before the catch-all /trainee router.
  4. /partner and /automation.
  5. /public/sign, /public/onboarding, /public.
  6. Every /web/* module.
  7. router.use(replay).
  8. The three bearer routers.
Everything before step 7 either needs no nonce or consumes one itself (requireServiceAuth calls nonceStore.consume). Putting those lanes first means a signed request is never asked for a second nonce.
router.use(replay) has no path. Any /v1 request that did not match an earlier mount reaches it. A typo such as /v1/clinets therefore answers 400 BAD_REQUEST with missing x-nonce or x-timestamp header, not 404. If you see that error from a client that should not need replay headers, the path is wrong.

Operational

/healthz always answers 200 with { "ok": true }. /readyz runs the readiness checks. /metrics serves the Prometheus default metrics from prom-client. None of them is authenticated or rate limited, and the HTTP logger skips them. See Observability.

Auth and billing tier (/api)

app.use('/api', honoGateway) forwards the request to the Hono app exported by @repo/api. That app serves: This tier has its own CORS policy (origins from getTrustedOrigins, credentials allowed) and is not behind the /v1 rate limiter. See Authentication and the api package.

Hosted MCP (/mcp)

POST /mcp is a stateless Streamable HTTP MCP endpoint. It requires Authorization: Bearer with a key that starts with pf_live_, otherwise it answers 401 with a WWW-Authenticate header and a JSON-RPC error. Each tool call is executed by calling /v1/automation on 127.0.0.1 with the same key, so the automation lane does the real key check. GET /mcp answers 405. See MCP.

Webhook lane

Two routes receive calls from SmartSend: SmartSend cannot sign its callbacks, so both read the token query parameter and call ctx.smartsend.verifyWebhookToken(token). That function does a timing safe compare against SMARTSEND_WEBHOOK_SECRET and returns false when the secret is empty. A bad token throws UNAUTHORIZED.

Internal service lane

These routes require a valid HMAC signature and nothing else. There is no user context, so req.auth is not set. internalRouter exposes POST /studios and POST /coaches for provisioning. @repo/auth calls /v1/internal/coaches from its provisionCoach helper when an invitation is accepted or a member’s role changes. The signing contract is on the Service auth page.

Web gateway lane

The coach web app’s server signs every request and forwards the user it has already authenticated in headers. The lane trusts those headers only because the signature proves the caller holds SERVICE_AUTH_SECRET. webUserContext sets req.auth = { studioId, userId, role }. Note that studioId here is the backend Studio.id, not the organization id from the header. The middleware also provisions on first contact. See Middleware. Modules mounted on this lane: crm, dashboard, monitoring, studios/current/api-keys, studios, onboarding, billing, autofit-import, notification-configs, studio/notification-settings, clients, coaches, products, programs, program-templates, program-folders, exercises, foods, content, home-banner, forms, tracking, technique-videos, inbox, task-automations, assistant, checkins, plan-import, forms-ai, automation, calendar, audit, whatsapp.
/web/studios/current/api-keys is mounted before /web/studios so the more specific router wins.

Bearer lane

The caller sends a better-auth session token plus the replay headers:
authenticate(sessionVerifier) turns the token into a principal. The verifier is chosen once at router build time:
  • SESSION_VERIFY_INPROCESS false: createSessionVerifier(BETTER_AUTH_URL) does GET <BETTER_AUTH_URL>/api/auth/get-session with the bearer header.
  • SESSION_VERIFY_INPROCESS true: createInProcessSessionVerifier(ctx.auth) calls auth.api.getSession directly.
Both map the result with principalFromSession in modules/auth/session-verifier.ts: A session without a user or an active organization is rejected as UNAUTHORIZED.
On this lane req.auth.studioId is the better-auth organization id, taken straight from the session. On the web lane it is the backend Studio.id. The same routers (studios, clients, whatsapp) are mounted on both. Nothing in the bearer path maps the organization id to a studio row, so a repository filter on studioId only matches when the two ids happen to be equal. The trainee app does not use this lane (it uses /v1/trainee), and the coach web app uses /v1/web. Treat the bearer lane as legacy and confirm the id mapping before building on it.

Trainee lane

The trainee mobile app signs in with a phone number and a WhatsApp code, then sends a self signed trainee token as Authorization: Bearer. It never uses better-auth. The principal is req.trainee = { clientId, studioId, preview? }. Routes that stay open to a frozen trainee are the ones registered with auth only: /me, /app-release, DELETE /me, the push token routes and auth/switch-studio. Full detail is on Trainee sessions. The trainee lane sends no replay headers and no signature. It is rate limited per IP by the /v1 limiter.

Partner lane

/v1/partner/* is a read mostly API used by SmartSend. Its router starts with router.use(requirePartnerKey(...)). The caller sends Authorization: Bearer pf_live_.... requirePartnerKey in modules/partner/partner-auth.ts:
  1. Rejects a missing header or a value without the pf_live_ prefix with UNAUTHORIZED.
  2. Hashes the key with SHA-256 and looks the hash up (apiKeyByHash). Keys are never stored in clear.
  3. Rejects when no row matches, when revokedAt is set, or when the key’s studio has deletedAt set.
  4. Stamps lastUsedAt at most once every 5 minutes, fire and forget.
  5. Sets req.partner = { studioId, apiKeyId }.
One key maps to exactly one studio. Handlers read the studio with partnerOf(req). Keys are created and revoked from the web lane at /v1/web/studios/current/api-keys, which is gated to OWNER and HEAD_COACH. Responses use the standard envelope. The comment in the automation router calls this lane a frozen wire contract with SmartSend, so do not change its shapes.

Automation API lane

/v1/automation/* uses the same requirePartnerKey guard and the same keys, but has its own response envelope and its own error middleware (automationErrorMiddleware), registered on the router. It is the lane for Make.com and for the MCP tools.
The router keeps two instances of each service that can notify a trainee, a loud one with the real notifier and a silent one, and picks per request. Bulk automation must not spray push notifications at trainees by default.

Public lane

The signing lane has a special rule. The trainee’s browser never calls the API directly. The web app proxies the request, so every signing request arrives from one address. To keep the proxy from exhausting the per IP limiter:
  • app.ts skips the global limiter when the path is under /v1/public/sign and the request carries x-service-id.
  • The signing router then requires a valid service signature for any request that carries x-service-id. A request that claims to be the proxy but is not signed is refused.
  • A direct caller without x-service-id is not signed and is counted by the global limiter under its own address.
  • In both cases the lane applies its own Redis throttle per link and per visitor IP (form-sign.throttle.ts).
This lane also answers in its own shape, { error, code }, with Hebrew messages, and sets Cache-Control: no-store and X-Robots-Tag: noindex.

Choosing a lane for new work