> ## 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.

# Forms

> Create and manage reusable intake and check-up form templates.

Forms are reusable company templates. Each template has `formType: "intake"`
or `formType: "checkup"`. Client actions are documented separately under
[Intakes](/public-api/intakes) and [Check-ups](/public-api/checkups).

| Scope         | Access                                                |
| :------------ | :---------------------------------------------------- |
| `forms:read`  | List templates and read their sections and questions. |
| `forms:write` | Create, update and archive templates.                 |

All writes require an `Idempotency-Key` header. Company ownership and creator
attribution come from the OAuth token. These endpoints do not accept client
answers, ownership IDs, objectives, measurements, media URLs or AI metadata.

## Localized text

Form names and descriptions, section titles and descriptions, question names,
choice labels, and linear-scale endpoint labels are language maps:
`{ "nl": "Wat is je doel?", "en": "What is your goal?" }`.
This contract applies to intake and check-up templates.

`defaultLanguage` is required when creating a form. Every required text field
and every non-empty optional text map must include that language. Keys use valid
BCP 47 tags such as `nl`, `en`, `en-GB`, or `zh-Hant`. Keys are canonicalized
on write (`en-gb` becomes `en-GB`); duplicate canonical keys are rejected.
Each text field supports up to 25 languages with non-empty string values.

Reads return all stored languages. Display the exact requested language when
available, otherwise the field's `defaultLanguage` value. Optional fields may
be empty objects. The API does not select languages from headers or profiles,
generate translations, or translate historical answers. Internal editors, apps,
assignment summaries and submitted answers retain their existing behavior;
this contract prepares Public API template authoring only.

```json theme={null}
{
  "defaultLanguage": "nl",
  "name": { "nl": "Intake", "en": "Initial assessment" },
  "formType": "intake",
  "sections": [
    {
      "title": { "nl": "Algemeen", "en": "General" },
      "questions": [
        {
          "name": { "nl": "Wat is je doel?", "en": "What is your goal?" },
          "type": "Multiple Choice",
          "options": [
            { "label": { "nl": "Sterker worden", "en": "Get stronger" } },
            { "label": { "nl": "Fitter worden", "en": "Get fitter" } }
          ]
        }
      ]
    }
  ]
}
```

Each saved choice option returns `{ "optionId": "...", "label": { ... } }`.
Retain `optionId` when changing its labels or position through the Public API;
it identifies the option independently of its text and language. Omit it for
new options. IDs are scoped to a question, and duplicate or foreign IDs are
rejected. Default-language labels must also be unique within a question while
internal answers still use source text. These endpoints do not accept answers;
existing submission endpoints retain their historical answer format.

For Linear Scale questions, `startLabel` and `endLabel` are language maps in
`linearScale`. The numeric endpoints remain numbers. Question/section IDs,
types, required flags and numeric configuration are language-independent.

### Updating localized text

Use `PATCH /public/v1/forms/{formId}`:

* Omitted top-level fields preserve their current values.
* A supplied text map replaces that field's complete map. For example,
  `{"name":{"nl":"Intake","en":"Assessment"}}` retains exactly these
  two languages for `name`. Include all languages you want to keep.
* Use `{}` to clear an optional text field. Required text cannot be empty.
  Plain strings, `null` maps and `null` language entries are rejected.
* `sections`, when supplied, replaces all sections and questions. Include
  complete text maps and existing section/question/option IDs for retained
  items; omit IDs for new items. Omitted section descriptions or scale endpoint
  labels in this replacement are cleared.
* Changing `defaultLanguage` requires every retained non-empty text map to
  contain the new language. The selected text becomes the source string used
  by internal forms.

The answered-form edit lock also applies to translation-only updates. Existing
answers are not rewritten. Invalid language tags, missing default-language text,
or invalid map values return `400 PUBLIC_API_INVALID_FORM_LOCALIZATION`.
Invalid or foreign item IDs return `400 PUBLIC_API_INVALID_FORM_ITEM_ID`.

### Existing internal forms

A form without a declared source language returns `defaultLanguage: null` and
its text under `und`, for example `"name": {"und":"Intake"}`. Its options
return `optionId: null` until saved through the Public API. GET requests do not
allocate IDs or change existing data. Do not guess the source language.

The first Public API update must declare the source language. For example,
`{"defaultLanguage":"nl"}` labels the existing source text as Dutch and saves
option IDs, without translating any text. Subsequent reads return regular `nl`
maps and persisted IDs. `und` is reserved for read responses and is rejected
in write payloads. If supplying sections during this first update, replace
`und` with the declared language and omit null option IDs.

Internal editing does not manage these translations yet. If an internal edit
changes a source string, Public API reads return only the updated source text
for that field, avoiding stale translations. An internal choice whose source
label no longer matches a stored option has no option ID until its next Public
API save. Internal duplication follows
the existing internal behavior and produces a form without public localization.

## Create a form

```http theme={null}
POST /public/v1/forms
Authorization: Bearer <access_token>
Idempotency-Key: intake-template-001
Content-Type: application/json
```

Required scope: `forms:write`. The OAuth client must have a coach attribution
context. The request creates an unassigned template; use the intake or check-up
assignment endpoint to assign it to a client.

```json theme={null}
{
  "defaultLanguage": "en",
  "name": { "en": "Initial intake" },
  "description": { "en": "Getting to know you" },
  "formType": "intake",
  "sections": [
    {
      "title": { "en": "General" },
      "questions": [
        {
          "name": { "en": "What would you like to achieve?" },
          "type": "Open Question",
          "required": true
        }
      ]
    }
  ]
}
```

`name`, `formType`, and `defaultLanguage` are required. `description` and
`sections` are optional.
Each section requires `title` and a `questions` array; its `description` is
optional. Each question requires `name` and `type`. The optional `required`
field defaults to `false`, and `order` defaults to the question's position.

| Question type                             | Additional configuration                                                                  |
| :---------------------------------------- | :---------------------------------------------------------------------------------------- |
| `Short Answer`, `Open Question`, `Yes/No` | No additional fields required.                                                            |
| `Multiple Choice`, `CheckList`            | Non-empty `options` array with localized `label` maps.                                    |
| `Linear Scale`                            | `linearScale` with numeric `startValue < endValue`; optional `startLabel` and `endLabel`. |
| `Ratings`                                 | Integer `ratingScale`: `5`, `6`, `7`, `8`, `9` or `10`.                                   |
| `Photo`, `Video`                          | Optional integer `maxFiles`, from `1` to `5`.                                             |

Numeric configuration accepts localized comma decimals and returns canonical
numbers. Integer fields still require whole numbers. Unknown fields and invalid
question configuration return `400`. Omit section, question and option IDs on creation.

Returns `201` with `data.form`, using the same fields as Get form detail below.

## Update a form

```http theme={null}
PATCH /public/v1/forms/{formId}
Authorization: Bearer <access_token>
Idempotency-Key: intake-template-update-001
Content-Type: application/json
```

Required scope: `forms:write`. Send at least one of `name`, `description`,
`formType`, `sections`, or `defaultLanguage`. Omitted fields
retain their current values.

```json theme={null}
{ "name": { "en": "Updated intake" } }
```

When supplied, `sections` replaces the complete sections and questions array.
Include existing `sectionId`, `questionId`, and `optionId` values from Get form detail to
retain those items, and omit IDs for new items. Question IDs must belong to the
specified section; duplicate or foreign IDs are rejected.

The `formType` cannot change. A form with any existing answers returns
`400 FORM_ALREADY_HAS_ANSWERS_CANNOT_EDIT`. Archived, deleted and inaccessible
templates return `404`. Internal objectives, measurements, media and AI metadata
remain unchanged. Returns `200` with `data.form`.

## Archive a form

```http theme={null}
POST /public/v1/forms/{formId}/archive
Authorization: Bearer <access_token>
Idempotency-Key: intake-template-archive-001
```

Required scope: `forms:write`. Omit the body or send `{}`. Other body fields are
rejected. Archiving excludes the template from template reads and prevents new
intake and check-up assignments. Existing client assignments and answers remain
available. Repeating this action keeps the form archived. It can archive a form
that already has answers; it does not delete it.

Returns `200` with:

```json theme={null}
{
  "data": {
    "form": { "formId": "66f7b8b1e13c8d25f4d3d90a", "archived": true }
  },
  "meta": { "requestId": "req_example" }
}
```

## List forms

```http theme={null}
GET /public/v1/forms?formType=intake
Authorization: Bearer <access_token>
```

Required scope: `forms:read`

Query parameters:

| Parameter  | Type    | Required | Rule                          |
| :--------- | :------ | :------- | :---------------------------- |
| `formType` | string  | no       | `intake` or `checkup`.        |
| `page`     | integer | no       | Minimum `1`.                  |
| `limit`    | integer | no       | Default `100`, maximum `100`. |

Response fields:

| Field                          | Type    | Nullable | Description                                   |
| :----------------------------- | :------ | :------- | :-------------------------------------------- |
| `data.forms[].formId`          | string  | no       | Form ID.                                      |
| `data.forms[].name`            | object  | no       | Form name by language.                        |
| `data.forms[].description`     | object  | no       | Form description by language.                 |
| `data.forms[].defaultLanguage` | string  | yes      | Explicit base language, if configured.        |
| `data.forms[].formType`        | string  | no       | `intake` or `checkup`.                        |
| `data.forms[].sectionCount`    | integer | no       | Number of sections.                           |
| `data.forms[].questionCount`   | integer | no       | Number of questions.                          |
| `data.forms[].hasObjectives`   | boolean | no       | Whether objectives are configured.            |
| `data.forms[].hasMeasurements` | boolean | no       | Whether measurement questions are configured. |
| `data.forms[].createdAt`       | string  | yes      | Creation timestamp.                           |
| `data.forms[].updatedAt`       | string  | yes      | Update timestamp.                             |

Form sections, question definitions, objective definitions, and media are not
included in this metadata endpoint.

<CodeGroup>
  ```bash cURL theme={null}
  curl -H "Authorization: Bearer $FITSOCIETY_ACCESS_TOKEN" \
    "https://api.fitsociety.io/public/v1/forms?formType=intake&page=1&limit=100"
  ```

  ```javascript JavaScript theme={null}
  const query = new URLSearchParams({
    formType: "intake",
    page: "1",
    limit: "100",
  });

  const response = await fetch(
    `https://api.fitsociety.io/public/v1/forms?${query}`,
    {
      headers: {
        Authorization: `Bearer ${process.env.FITSOCIETY_ACCESS_TOKEN}`,
      },
    },
  );

  const body = await response.json();
  console.log(body.data.total, body.data.forms);
  ```

  ```python Python theme={null}
  import os
  import requests

  response = requests.get(
      "https://api.fitsociety.io/public/v1/forms",
      headers={"Authorization": f"Bearer {os.environ['FITSOCIETY_ACCESS_TOKEN']}"},
      params={"formType": "intake", "page": 1, "limit": 100},
  )
  response.raise_for_status()

  body = response.json()
  print(body["data"]["total"], body["data"]["forms"])
  ```
</CodeGroup>

Response:

```json theme={null}
{
  "data": {
    "page": 1,
    "limit": 100,
    "total": 1,
    "totalPages": 1,
    "hasNextPage": false,
    "hasPrevPage": false,
    "forms": [
      {
        "formId": "64b64c0f2f5f4c00123456d0",
        "name": { "en": "New client intake" },
        "description": { "en": "Intake questionnaire for new clients" },
        "defaultLanguage": "en",
        "formType": "intake",
        "sectionCount": 3,
        "questionCount": 18,
        "hasObjectives": true,
        "hasMeasurements": false,
        "createdAt": "2026-05-01T09:00:00.000Z",
        "updatedAt": "2026-07-01T09:00:00.000Z"
      }
    ]
  },
  "meta": {
    "requestId": "req_0123456789abcdef",
    "rateLimit": { "limit": 10, "remaining": 9, "resetSeconds": 1 }
  }
}
```

## Get form detail

```http theme={null}
GET /public/v1/forms/{formId}
Authorization: Bearer <access_token>
```

Required scope: `forms:read`

This endpoint returns a sanitized form structure for integrations that need to
render supported questions. It does not return objectives, medical/nutrition
blocks, measurement configuration, form media, AI metadata, coach IDs, or raw
model fields.

Response fields:

| Field                                                     | Type    | Nullable | Description                                                                 |
| :-------------------------------------------------------- | :------ | :------- | :-------------------------------------------------------------------------- |
| `data.form.formId`                                        | string  | no       | Form ID.                                                                    |
| `data.form.name`                                          | object  | no       | Form name by language.                                                      |
| `data.form.description`                                   | object  | no       | Form description by language.                                               |
| `data.form.defaultLanguage`                               | string  | yes      | Explicit base language, if configured.                                      |
| `data.form.formType`                                      | string  | no       | `intake` or `checkup`.                                                      |
| `data.form.sectionCount`                                  | integer | no       | Number of sections.                                                         |
| `data.form.questionCount`                                 | integer | no       | Number of questions.                                                        |
| `data.form.hasObjectives`                                 | boolean | no       | Whether hidden objective blocks exist.                                      |
| `data.form.hasMeasurements`                               | boolean | no       | Whether measurement blocks exist.                                           |
| `data.form.sections[].sectionId`                          | string  | no       | Section ID.                                                                 |
| `data.form.sections[].title`                              | object  | no       | Section title.                                                              |
| `data.form.sections[].description`                        | object  | no       | Section description.                                                        |
| `data.form.sections[].questions[].questionId`             | string  | no       | Question ID.                                                                |
| `data.form.sections[].questions[].name`                   | object  | no       | Question label.                                                             |
| `data.form.sections[].questions[].type`                   | string  | no       | Question type.                                                              |
| `data.form.sections[].questions[].required`               | boolean | no       | Required flag.                                                              |
| `data.form.sections[].questions[].options[]`              | object  | no       | Choice option with identity and localized label.                            |
| `data.form.sections[].questions[].options[].optionId`     | string  | yes      | Persisted option ID; null until first Public API save for internal options. |
| `data.form.sections[].questions[].options[].label`        | object  | no       | Display label by language.                                                  |
| `data.form.sections[].questions[].linearScale`            | object  | yes      | Linear scale metadata when configured.                                      |
| `data.form.sections[].questions[].linearScale.startValue` | number  | yes      | Linear scale start value.                                                   |
| `data.form.sections[].questions[].linearScale.endValue`   | number  | yes      | Linear scale end value.                                                     |
| `data.form.sections[].questions[].linearScale.startLabel` | object  | no       | Linear scale start label.                                                   |
| `data.form.sections[].questions[].linearScale.endLabel`   | object  | no       | Linear scale end label.                                                     |
| `data.form.sections[].questions[].ratingScale`            | number  | yes      | Rating scale maximum when configured.                                       |
| `data.form.sections[].questions[].maxFiles`               | number  | yes      | Media count limit for media questions.                                      |
| `data.form.sections[].questions[].order`                  | number  | yes      | Display order.                                                              |
