> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fitsociety.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Contracts, validation, and rate limits

> Stable response envelopes, field compatibility rules, validation behavior, pagination, errors, and rate-limit headers for the Public API.

The Public API is contract-first. Resource endpoints expose explicit DTO fields,
not raw database documents. FITsociety may add fields to a response, but existing
v1 fields are not renamed, removed, or changed to another type without a new API
version.

## DTO allowlist policy

Public API responses are built from explicit DTO allowlists. Adding a field to a
MongoDB model, internal controller response, MCP tool result, provider payload,
or coach app endpoint does not automatically add that field to the Public API.

Every new Public API endpoint must document each returned field on its endpoint
page. New response fields require a deliberate DTO change, Swagger update,
Mintlify update, frontend plugin metadata update when relevant, and contract
tests for sensitive-field omissions.

The Public API does not expose these internal fields unless an endpoint page
explicitly documents a safe masked or derived version:

| Field category             | Examples                                                                                                     |
| :------------------------- | :----------------------------------------------------------------------------------------------------------- |
| Authentication secrets     | Passwords, password hashes, access tokens, refresh tokens, API keys, client secrets, OAuth token hashes      |
| Provider internals         | Raw payment-provider payloads, webhook payloads, provider authorization headers, provider customer snapshots |
| Payment mandates           | Full IBAN, full BIC, mandate objects, bank verification payloads                                             |
| Storage internals          | Raw S3 keys, private media paths, unsigned private file URLs                                                 |
| Health/provider provenance | Raw health provider source refs, derivation refs, suppression metadata, quality flags                        |
| Private communication      | Chat message bodies, push tokens, unread-state internals, raw participant auth fields                        |
| Form internals             | Objective medical/nutrition blocks, draft answers, media answer URLs, reminder email/chat bodies             |
| Internal operations        | Sentry context, stack traces, background job debug state, internal audit implementation details              |
| Internal notes             | Staff-only notes, deletion metadata, moderation state, hidden support context                                |

When a public workflow needs a sensitive-derived value, return a purpose-built
field instead. Examples: `pdfAvailable` instead of `pdfLink`, a short-lived
`downloadUrl` instead of a storage key, and masked bank-account identifiers
instead of full account details.

## Compatibility policy

| Change type                                       | v1 behavior                                        |
| :------------------------------------------------ | :------------------------------------------------- |
| Add a response field                              | Allowed without a version bump                     |
| Add an enum value                                 | Allowed when the field is documented as extensible |
| Rename a response field                           | Not allowed in v1                                  |
| Remove a response field                           | Not allowed in v1                                  |
| Change a field type                               | Not allowed in v1                                  |
| Make an optional field required in request bodies | Not allowed in v1                                  |
| Make a required field optional in request bodies  | Allowed                                            |
| Add a new endpoint                                | Allowed                                            |

Clients should ignore unknown response fields. Clients should not depend on
object key order.

## Base URL

```txt theme={null}
https://api.fitsociety.io/public/v1
```

## Authentication

Resource endpoints require:

```http theme={null}
Authorization: Bearer <access_token>
```

Tokens are issued by `POST /public/v1/oauth/token` using OAuth client
credentials. Each token has explicit scopes. A request must include every scope
required by the endpoint.

## Resource response envelope

Bearer resource endpoints use the Public API envelope:

| Field                              | Type                     | Nullable | Description                                                                                 |
| :--------------------------------- | :----------------------- | :------- | :------------------------------------------------------------------------------------------ |
| `data`                             | object, array, or `null` | no       | Endpoint-specific response payload.                                                         |
| `meta.requestId`                   | string                   | no       | Stable request identifier. Also returned as `X-Request-Id`.                                 |
| `meta.rateLimit`                   | object                   | yes      | Rate-limit state when the request passed through the Public API rate limiter.               |
| `meta.rateLimit.limit`             | integer                  | yes      | Request limit for the current rate-limit window.                                            |
| `meta.rateLimit.remaining`         | integer                  | yes      | Requests remaining in the current window.                                                   |
| `meta.rateLimit.resetSeconds`      | integer                  | yes      | Seconds until the current window resets.                                                    |
| `meta.rateLimit.retryAfterSeconds` | integer                  | yes      | Seconds to wait before retrying; present on `429` responses.                                |
| `meta.idempotency.replayed`        | boolean                  | yes      | Present and `true` when a successful write response was replayed from an `Idempotency-Key`. |

Example:

```json theme={null}
{
  "data": {
    "page": 1,
    "limit": 50,
    "total": 1,
    "totalPages": 1,
    "hasNextPage": false,
    "hasPrevPage": false,
    "bookings": []
  },
  "meta": {
    "requestId": "req_0123456789abcdef",
    "rateLimit": {
      "limit": 10,
      "remaining": 9,
      "resetSeconds": 1
    }
  }
}
```

OAuth token responses use the OAuth shape documented on the authentication page.

## Error envelopes

Bearer resource authentication, scope, validation, rate-limit, and server errors
use the Public API error envelope:

| Field                              | Type    | Nullable | Description                                                  |
| :--------------------------------- | :------ | :------- | :----------------------------------------------------------- |
| `error.code`                       | integer | no       | HTTP status code.                                            |
| `error.key`                        | string  | no       | Machine-readable error key.                                  |
| `error.message`                    | string  | no       | Human-readable message.                                      |
| `error.details`                    | object  | yes      | Additional validation details when available.                |
| `meta.requestId`                   | string  | no       | Stable request identifier. Also returned as `X-Request-Id`.  |
| `meta.rateLimit`                   | object  | yes      | Rate-limit state when available.                             |
| `meta.rateLimit.limit`             | integer | yes      | Request limit for the current rate-limit window.             |
| `meta.rateLimit.remaining`         | integer | yes      | Requests remaining in the current window.                    |
| `meta.rateLimit.resetSeconds`      | integer | yes      | Seconds until the current window resets.                     |
| `meta.rateLimit.retryAfterSeconds` | integer | yes      | Seconds to wait before retrying; present on `429` responses. |

Example:

```json theme={null}
{
  "error": {
    "code": 422,
    "key": "validation.failed",
    "message": "The request body is invalid.",
    "details": {
      "field": "email",
      "reason": "invalid_email"
    }
  },
  "meta": {
    "requestId": "req_0123456789abcdef",
    "rateLimit": {
      "limit": 10,
      "remaining": 8,
      "resetSeconds": 1
    }
  }
}
```

Do not build client logic from translated text. Use HTTP status, `error.key`,
endpoint context, and documented validation rules.

Error payloads do not include stack traces, raw provider payloads, token hashes,
secrets, authorization headers, Sentry context, or database documents.

## Idempotency

Bearer write requests require an `Idempotency-Key` header:

```http theme={null}
Idempotency-Key: <stable_request_key>
```

Idempotency applies to `POST`, `PUT`, `PATCH`, and `DELETE` resource requests.
The key is scoped to the authenticated company and Public API client.

| Rule                                                                     | Behavior                                                                         |
| :----------------------------------------------------------------------- | :------------------------------------------------------------------------------- |
| Missing key on a write                                                   | `400 idempotency.required`                                                       |
| Key longer than 200 characters                                           | `400 idempotency.invalid`                                                        |
| Same key, same method, same URL path/query, same body, already completed | Replays the stored successful response with `meta.idempotency.replayed=true`.    |
| Same key, different method, URL path/query, or body                      | `409 idempotency.conflict`                                                       |
| Same key while the first request is still processing                     | `409 idempotency.in_progress`                                                    |
| Request returns `4xx` or `5xx`                                           | The key is not completed and may be retried after the underlying issue is fixed. |

Only successful `2xx` and `3xx` write responses are stored for replay. Use a
new key for a new user action, and reuse a key only for retries of the exact same
request.

## Access-device contracts

`/public/v1/access/*` is intentionally separate from the OAuth resource
contract. It uses device-key authentication and the FITsociety application
envelope:

| Field     | Type    | Nullable | Description                                                  |
| :-------- | :------ | :------- | :----------------------------------------------------------- |
| `status`  | integer | no       | HTTP status code.                                            |
| `error`   | boolean | no       | `false` for access decisions, `true` for malformed requests. |
| `message` | string  | no       | Stable access message key.                                   |
| `data`    | object  | yes      | Access decision payload.                                     |

Access decisions return HTTP `200` for both allow and deny outcomes. Read
`data.allowed` and `data.reasonCode`. Access validation has its own per-device
rate limit and does not require `Idempotency-Key`.

## Rate limits

Bearer resource calls are rate limited per Public API OAuth client:

| Limit               | Value           |
| :------------------ | :-------------- |
| Window              | 1 second        |
| Requests per window | 10              |
| Bucket key          | OAuth client ID |

Every authenticated resource response includes rate-limit data in
`meta.rateLimit` and rate-limit headers:

| Header                  | Type    | Description                                                       |
| :---------------------- | :------ | :---------------------------------------------------------------- |
| `RateLimit-Limit`       | integer | Maximum requests in the current window.                           |
| `RateLimit-Remaining`   | integer | Requests left in the current window.                              |
| `RateLimit-Reset`       | integer | Seconds until the current window resets.                          |
| `X-RateLimit-Limit`     | integer | Backward-compatible mirror of `RateLimit-Limit`.                  |
| `X-RateLimit-Remaining` | integer | Backward-compatible mirror of `RateLimit-Remaining`.              |
| `X-RateLimit-Reset`     | integer | Backward-compatible mirror of `RateLimit-Reset`.                  |
| `Retry-After`           | integer | Only present on `429` responses. Seconds to wait before retrying. |

Rate-limit response:

```json theme={null}
{
  "error": {
    "code": 429,
    "key": "rate_limit.exceeded",
    "message": "Too many Public API requests for this client."
  },
  "meta": {
    "requestId": "req_0123456789abcdef",
    "rateLimit": {
      "limit": 10,
      "remaining": 0,
      "resetSeconds": 1,
      "retryAfterSeconds": 1
    }
  }
}
```

## Pagination fields

List endpoints use 1-based pagination unless explicitly documented otherwise.

| Field         | Type    | Nullable | Description                              |
| :------------ | :------ | :------- | :--------------------------------------- |
| `page`        | integer | no       | Current 1-based page.                    |
| `limit`       | integer | no       | Page size after server caps are applied. |
| `total`       | integer | no       | Total matching records.                  |
| `totalPages`  | integer | no       | Number of pages, minimum `1`.            |
| `hasNextPage` | boolean | no       | Whether `page + 1` exists.               |
| `hasPrevPage` | boolean | no       | Whether `page - 1` exists.               |

Default and maximum limits differ per endpoint. The endpoint pages document the
exact cap.

## Date and time rules

| Input type          | Rule                                            |
| :------------------ | :---------------------------------------------- |
| Date-only fields    | Use canonical `YYYY-MM-DD`.                     |
| Date-time fields    | Use ISO 8601 with `Z` or an explicit offset.    |
| Response date-times | UTC ISO strings when serialized by the API.     |
| Timezone display    | Clients are responsible for display conversion. |

Range endpoints reject invalid dates, `dateTo < dateFrom`, and ranges above the
documented maximum.

## Numeric input rules

Public write endpoints accept JSON numbers for numeric fields. Measurement
values also accept localized numeric strings such as `"82,5"` or `"1.250,5"`.

Numeric fields reject display strings with units or labels:

```json theme={null}
{
  "value": "82.5 kg"
}
```

Send units in their own documented field.

## Object ID rules

IDs are MongoDB ObjectId strings. Invalid ID shape returns `400`. Valid IDs that
do not belong to the authenticated company or client relationship return `404`
or `403` depending on the underlying access check.

The Public API never accepts `companyId` from resource request bodies. The
company is always derived from the Bearer token.

## Short-lived download and booking tokens

| Token or URL                      | Issuer                                               | TTL                   | Validation                                                                                                          |
| :-------------------------------- | :--------------------------------------------------- | :-------------------- | :------------------------------------------------------------------------------------------------------------------ |
| `availabilityToken`               | `GET /public/v1/availability`                        | 10 minutes            | HMAC signature, company match, expiry, and live slot bookability on booking.                                        |
| `invoicePdf.downloadUrl`          | `GET /public/v1/finance/invoices/{invoiceId}/pdf`    | 15 minutes            | Bearer scope, company scope, invoice access, existing `pdfLink`, and media signer when configured.                  |
| `progressPhoto.image.downloadUrl` | `GET /public/v1/clients/{clientId}/progress-photos*` | 15 minutes by default | Bearer scope, client access, historical progress-photo grant, existing image URL, and media signer when configured. |

Availability tokens are not reservations. Callers should submit the token to
`POST /public/v1/bookings` quickly and handle stale-capacity errors.

Invoice PDF URLs are read-only metadata for an existing PDF. The Public API does
not generate missing PDFs on read endpoints.

Progress photo URLs expose a signed or normalized media URL only. The raw
storage URL, storage key, uploader actor IDs, dimensions, and file-size metadata
remain internal.
