These rules hold across the core API unless a page says otherwise. They are read from src/http/respond.ts, packages/errors, packages/types, packages/security, packages/observability and src/middleware/rate-limit.ts.

Response envelope

Controllers answer through three helpers in src/http/respond.ts.
Errors come from errorMiddleware in packages/errors:
details is present only when the error carries one. requestId is omitted when the request has none, which does not happen behind requestIdMiddleware.

Surfaces with a different shape

Error codes

ErrorCode is defined in packages/types/src/index.ts. AppError maps each code to a default status in packages/errors/src/index.ts. A throw site can override the status with options.status.

Errors that are mapped for you

normalize in packages/errors turns a few non-AppError failures into codes: Internal error text never reaches the client for unmapped failures. The middleware logs 5xx with [error] and 4xx with [client-error], both with the request id, path, method, code and message.

Reasons in details

Some errors carry a machine-readable details.reason that clients branch on: The meal analyzer uses details.easterEgg (alcohol or cannabis) for rejected photos. See Trainee nutrition.

Validation

Controllers parse req.params, req.query and req.body with a Zod schema from the module’s *.schema.ts, then call the service with typed values. A thrown ZodError becomes:
Things to know:
  • Numbers in query strings and many bodies use z.coerce.number(), so "72.5" is accepted where a number is expected.
  • Dates use z.coerce.date() for instants and a YYYY-MM-DD regex or z.iso.date() for calendar days.
  • Schemas are plain z.object, so unknown keys are stripped, not rejected.
  • Trainee form answers are validated by the form engine, not Zod. A failed answer returns 400 BAD_REQUEST with a Hebrew message naming the field and a details array of { key, label, message }. See Trainee forms.
  • The automation lane coerces much harder (empty strings mean “not provided”, "true" is a boolean) and reports validation as 400 with one readable sentence. See Automation API.

Pagination and sorting

There is no single pagination scheme. Three patterns exist. The pageSize maximum is set per schema. It is 500 on the web lists read for this reference (clients, forms, content) and 100 on the partner workouts list. The trainee content list accepts page and pageSize (default 100, max 500) and returns { items, total } without echoing the page. Fixed caps on the trainee lane: workout history returns the latest 100 completed logs, meal history the latest 50, progress photos the latest 12, the measurements log 120 rows, cardio history at most 90 days, and the shopping food list 500 rows. No endpoint in these lanes takes a sort parameter. Order is fixed in each repository query and stated on the endpoint.

Dates and time zones

  • Instants are DateTime columns and serialize as ISO 8601 UTC strings, for example 2026-10-07T06:15:00.000Z.
  • Calendar days are YYYY-MM-DD strings in requests and in most computed responses. Columns typed @db.Date (DailyMetric.date, CardioLog.performedOn, NutritionDayLog.date) store the day as UTC midnight of that day key. When a raw Prisma row is returned, such a column serializes as 2026-10-07T00:00:00.000Z.
  • “Today” for a trainee is computed in the trainee’s time zone. resolveTimeZone in src/lib/timezone.ts takes the first valid value of Client.timezone, then Studio.timezone, then the default Asia/Jerusalem.
  • Client.timezone is written by the trainee lane itself. Every authenticated trainee request with a valid IANA zone in x-timezone updates it, with a 12 hour in-memory cache per client. Preview tokens never write it.
  • tzDayWindow(iso, timeZone) gives the UTC instants that bound a local day. Instant columns such as MealLog.consumedAt and WorkoutLog.performedOn are filtered with that window. Date columns are matched by day key.
  • The trainee calendar is the exception. Slots are generated in the studio’s time zone only.
  • Weeks in the trainee app start on Sunday. currentWeekIsoDates builds the seven day keys from the Sunday on or before today.
  • Money is stored in agorot as integers, for example priceAgorot.

Ids

  • Database ids are Prisma cuid() strings. Treat them as opaque.
  • Request ids are ULIDs from ulidx.
  • Studio API keys are pf_live_ plus 40 hex characters. Webhook signing secrets are 48 hex characters.
  • A few ids are composite strings built at read time and never stored as rows: activity feed ids such as workout:<id>, form:<id> and video:<id>, and nutrition swap option ids that start with auto:.
  • Ids inside program content (days, rows, meals, items) live in the program JSON, not in their own tables. A trainee write that names one, such as an exercise substitution, is checked against the current content.
  • Trainees created through the automation lane can carry a caller-supplied externalId. It is stored on Client.externalUserId as automation:<studioId>:<externalId> and returned without the prefix.
  • Tenancy is never taken from the path or body. The studio always comes from the authenticated principal.

Rate limits

Global limiters

Both are express-rate-limit instances created in src/middleware/rate-limit.ts and wired in src/app.ts. They send the standard RateLimit-* headers and no legacy X-RateLimit-* headers. Details that matter in practice:
  • /v1 and /mcp share one limiter instance, so a caller’s requests to both count in the same bucket.
  • Requests to /v1/public/sign/* that carry x-service-id skip the global limiter. The signing lane counts them itself.
  • /api/*, /healthz, /readyz and /metrics are not behind either limiter.
  • When a global limiter trips, the response is the library’s default 429 body, not the JSON error envelope. Clients should branch on the status code.
  • trust proxy is set to exactly 1, so the key is the address forwarded by the single reverse proxy. A client cannot pick its own bucket by forging X-Forwarded-For.

Service-level throttles

These are counted in Redis and answer with the envelope and code RATE_LIMITED unless noted. Other fixed limits found in code: The signing throttle fails open: if Redis is unreachable the request is allowed.

Request ids

requestIdMiddleware in packages/observability runs on every request. If the caller sends a non-empty x-request-id header it is reused. Otherwise a ULID is generated. The id is stored on req.requestId, returned in the X-Request-Id response header, and included as requestId in every envelope. It is also on the error log line, so a support report with a request id can be traced.

Replay protection

Two mechanisms use the same Redis nonce store (createRedisNonceStore, key perform:nonce:<nonce>, SET NX):
  • Service signatures. requireServiceAuth rejects a timestamp more than 300 seconds from server time and consumes the nonce with a 360 second TTL. A replayed signed request fails with 401 nonce already used. This covers the web gateway, internal and admin lanes, and signed calls to the public signing lane.
  • replayProtection. A standalone middleware that requires x-nonce and x-timestamp and applies the same window and nonce check without any signature. It is mounted once, in front of the bearer lane only.
The trainee, partner, automation, public and webhook lanes have no replay protection. A captured trainee token or API key can be reused until it is revoked, so both must only travel over TLS. Idempotency is handled per endpoint where it matters. Water logging accepts a client requestId that makes a retry a no-op, and automation trainee creation accepts an externalId. Both are documented on their pages.

HTTP methods

  • GET reads. POST creates or performs an action. PATCH updates part of a resource. PUT replaces a whole document, such as the nutrition day snapshot. DELETE removes.
  • A create usually returns 201. The automation lane always returns 200.
  • A delete does not always return 204. Many trainee deletes return 200 with the deleted id so the app can update its cache. Each endpoint states its status.