There are two kinds of limit in the API:
  • Two express-rate-limit middlewares that protect whole lanes.
  • Feature level throttles in Redis, written by hand where a specific abuse case exists (OTP sends, signing links, AI usage).

HTTP limiters

Both are created in createApp from apps/core-api/src/middleware/rate-limit.ts. They share one window (RATE_LIMIT_WINDOW_MS, default 60 seconds) and send the standard RateLimit-* headers (standardHeaders: true, legacyHeaders: false). Both use the default in-memory store. Counters are per process and reset on restart.

Why the web lane has its own limiter

Every coach’s request reaches the API from the web app’s server, so all of them share one IP. A per IP limit would throttle the whole product at once. The v1 limiter therefore skips web paths:
The web limiter is the third element of the web guard array, after requireServiceAuth and webUserContext. By the time it runs req.auth exists, so the key is the studio and the user:
An unsigned request never reaches the web limiter. It is rejected by requireServiceAuth first.

Why trust proxy is exactly 1

req.ip is only the real client address when Express trusts the proxy in front of it. app.set('trust proxy', 1) trusts one hop. Before that setting, every trainee shared the proxy’s address and whole studios received 429 together. true would let a client forge X-Forwarded-For and choose its own bucket.

The public signing exception

The signing page is proxied by the web app, so those requests also arrive from one address. app.ts wraps the v1 limiter:
A request under /v1/public/sign that carries x-service-id skips the limiter, and the signing router then demands a valid signature for it. A direct caller is still counted per IP. The lane’s own throttle applies in both cases.

What is not limited by either

  • /healthz, /readyz, /metrics.
  • Everything under /api (better-auth, oRPC, the payments webhook). better-auth applies its own attempt limits to OTP codes (allowedAttempts: 3).

What a limited client sees

express-rate-limit answers 429 with its own plain text body. It does not go through errorMiddleware, so the response is not in the { ok: false, ... } envelope. Clients should branch on the status code.

Feature throttles in Redis

These live next to the feature. All keys start with perform:. The pattern is the same everywhere: INCR a key, set EXPIRE when the count is 1, compare with a maximum.

Trainee OTP

modules/trainee/trainee.service.ts. Past the limit request-otp throws RATE_LIMITED (429) in the standard envelope. The throttle is checked after the account lookup, so the 429 reveals nothing the 404 for an unknown number does not.

Coach login OTP

better-auth’s phoneNumber and emailOTP plugins are configured with a 6 digit code, 300 second expiry and 3 allowed attempts (packages/auth/src/auth.ts).

Onboarding wizard OTP

/v1/public/onboarding/* throttles phone and email codes per phone, per email and per IP, and counts failed verifications. Keys: perform:onboarding-otp-throttle:phone:, perform:onboarding-otp-throttle:ip:, perform:onboarding-otp-fails:, and the perform:onboarding-email-otp-* equivalents.

AutoFit import verification

The import wizard verifies a coach’s phone before pulling their data. Keys under perform:autofit-otp* throttle sends and checks per phone, per user and per studio.

Public form signing

modules/form-sign/form-sign.throttle.ts. A 600 second window, counted per link and per visitor IP for each action: The link token is hashed before it becomes part of the key, so a Redis key listing never shows a working link. If Redis is down the throttle logs a warning and allows the request. A Redis outage must not stop a trainee from signing a contract.

AI usage caps

These protect cost, not availability. Reaching one returns a friendly message in the chat instead of an HTTP error.

Other soft throttles

Some writes are throttled to save database work, not to stop abuse:
  • Coach.lastActiveAt is written at most once per 5 minutes per coach (in memory, web-context.ts).
  • StudioApiKey.lastUsedAt at most once per 5 minutes per key (LAST_USED_THROTTLE_MS).
  • Client.lastCheckInAt at most once per 15 minutes on /trainee/me (LAST_CHECK_IN_THROTTLE_MS).

Adding a limit

  • For a new public route, start with the v1 limiter it already has, then add a Redis throttle keyed by the thing being abused (phone, email, link, token) and by IP.
  • Decide what happens when Redis is unavailable. Authentication related throttles should fail closed. Throttles on a task a real user must complete should fail open and log.
  • Throw AppError(ErrorCode.RATE_LIMITED, message) so the client gets the standard envelope.
  • Hash any secret before using it in a key.