The forms module stores a studio’s form templates (FormTemplate), sends them to trainees as assignments (FormAssignment) and keeps the submitted answers (FormResponse). Source: backend/apps/core-api/src/modules/forms. Two routers are covered here: A third router in the same file, traineeFormsRouter at /v1/trainee/forms, serves the trainee app and is documented on its own page. Related pages: Forms AI creates draft templates from a link, files or pasted text. Form signing is the public lane for PDF_SIGNATURE forms. Update forms is the scheduler that sends recurring check-in forms and form reminders.

Auth and guards

Every /v1/web/forms request passes HMAC service auth from @perform/security, then middleware/web-context.ts:
  • x-studio-id and x-user-id are required. Without them the request fails with UNAUTHORIZED (401, missing web user context).
  • x-user-role is parsed into OWNER, HEAD_COACH, SUB_COACH or TRAINEE. Any other value, or no header, becomes SUB_COACH.
  • The studio is resolved from x-studio-id (Studio.externalOrgId) and created if it does not exist. An OWNER gets a HEAD_COACH row in Coach on first contact.
  • The result is req.auth with studioId, userId and role.
The forms router adds no requireRole and does not use middleware/coach-access.ts. Any caller that passes the web lane can call every endpoint below, and all data is scoped by req.auth.studioId only. A coach whose permissions restrict them to assigned trainees is not narrowed here. /v1/admin/default-forms has service auth only. There is no webUserContext, no req.auth and no role guard inside default-forms.routes.ts. Any service holding a valid service secret can manage the global default templates.

Shared concepts

Form types and status

Form schema

schema is accepted as a free JSON object and always passed through normalizeFormSchema (form-schema.ts) before it is stored, so what you read back is FormSchemaV2:
Field types (FORM_FIELD_TYPES): text, number, phone, israeli_id, age, height, weight, body_measurement, goal, email, gender, date, birth_date, dropdown, checkbox_confirmation, rating, range, image_upload, video_upload, food_preferences, read_more, coach_message, text_block, media_block, signature. read_more, coach_message, text_block and media_block are display only (DISPLAY_ONLY_TYPES) and are not counted in fieldCount. settings.displayName is the name the trainee sees. When it is absent the trainee sees the template name.

Template response shape

Every template endpoint returns the FormTemplate row plus two computed fields from enrichTemplate:
  • fieldCount: number of fields that are not display only.
  • responseCount: number of FormResponse rows for the template.

Errors common to all endpoints

Templates

GET /v1/web/forms/templates

Lists the studio’s templates, newest first (createdAt descending). Auth: web lane, any role.
string
One of INTAKE, CHECK_IN, ONE_TIME, PDF_SIGNATURE.
string
One of DRAFT, PUBLISHED, ARCHIVED.
boolean
Filters on FormTemplate.active. Parsed with z.coerce.boolean(), so any non-empty string, including false, is read as true. Omit the parameter to get both.
Case-insensitive contains match on name.
number
default:"1"
Positive integer.
number
default:"20"
Positive integer, maximum 500.
Response: data holds items (templates in the shape above), total, page and pageSize.
Errors: VALIDATION.

POST /v1/web/forms/templates

Creates a template. Responds with 201. Auth: web lane, any role.
string
required
INTAKE, CHECK_IN, ONE_TIME or PDF_SIGNATURE.
string
required
At least 1 character.
object
required
The form schema. Normalized to FormSchemaV2 before saving.
string
Optional description.
string | null
WEEKLY, BIWEEKLY or MONTHLY. Only kept for CHECK_IN, where it defaults to WEEKLY. Stored as null for every other type.
number | null
Integer 0 to 6. Only kept for CHECK_IN.
number | null
Integer 1 to 31. Only kept for CHECK_IN.
object
Free JSON stored on FormTemplate.logic. Forms AI writes logic.aiImport here and the AutoFit import reads logic.autofitImport.
string
default:"DRAFT"
DRAFT, PUBLISHED or ARCHIVED.
boolean
default:"true"
Inactive templates cannot be assigned.
For PDF_SIGNATURE, when the schema has settings.document, the router’s documentCheck (form-sign/source-document.ts) verifies the uploaded PDF before the row is written. See the document errors below. Response: the created template with fieldCount and responseCount (0). Errors:

GET /v1/web/forms/templates/:id

Returns one template of the caller’s studio. Auth: web lane, any role.
string
required
FormTemplate.id.
Response: the template with fieldCount and responseCount. Errors: NOT_FOUND (404, form template not found) when the id does not exist in this studio.

PATCH /v1/web/forms/templates/:id

Updates a template. Only the fields you send change. Auth: web lane, any role.
string
required
FormTemplate.id.
string
At least 1 character.
string
INTAKE, CHECK_IN, ONE_TIME or PDF_SIGNATURE. See the type change rules below.
string | null
WEEKLY, BIWEEKLY, MONTHLY or null.
number | null
Integer 0 to 6.
number | null
Integer 1 to 31.
string | null
Send null to clear it.
object
Replaces the schema after normalization.
object
Replaces logic.
string
DRAFT, PUBLISHED or ARCHIVED.
boolean
Turns the template on or off for sending.
Service rules (updateTemplate in forms.service.ts):
  • A PDF_SIGNATURE template cannot change type, and no other template can become PDF_SIGNATURE.
  • A template cannot become ONE_TIME while any Product or Client still references it through onboardingFormId or updateFormId.
  • Schedule fields are only written when the resulting type is CHECK_IN. Switching a template to CHECK_IN without a cadence sets WEEKLY. Switching away from CHECK_IN clears cadence, sendWeekday and sendDayOfMonth.
  • Every request that sends status: "PUBLISHED" increments version by 1, including when the template is already published. New assignments record the version at send time in FormAssignment.formVersion.
  • For PDF_SIGNATURE, a new schema triggers the document check only when settings.document.url or pageCount changed.
Response: the updated template. Errors:

GET /v1/web/forms/templates/:id/document

Streams the source PDF of a PDF_SIGNATURE template so the builder can render it with pdf.js. The handler is coachDocumentHandler in form-sign/form-sign.routes.ts. Auth: web lane, any role.
string
required
FormTemplate.id.
Response: the raw PDF bytes, not the JSON envelope. Headers: Content-Type: application/pdf, Cache-Control: private, no-store, X-Robots-Tag: noindex. Errors:

DELETE /v1/web/forms/templates/:id

Deletes a template. Responds with 204 and no body. Auth: web lane, any role.
string
required
FormTemplate.id.
The Prisma relations on FormAssignment and FormResponse use onDelete: Cascade, so deleting a template also deletes its assignments and responses. Errors: NOT_FOUND (404) when the template is not in this studio.

POST /v1/web/forms/templates/:id/duplicate

Copies a template. Responds with 201. Auth: web lane, any role.
string
required
The template to copy.
The copy keeps type, cadence, sendWeekday, sendDayOfMonth, description and the normalized schema. It is created with active: false, status: "DRAFT" and version: 1. The name is the original name followed by the Hebrew suffix (עותק). logic is copied without the autofitImport key (copiedLogic), so the AutoFit import cannot attach answers to the copy. Response: the new template with fieldCount and responseCount (0). Errors: NOT_FOUND (404).

Assignments

POST /v1/web/forms/templates/:id/assign

Sends a form to one trainee by creating a FormAssignment. Responds with 201. Auth: web lane, any role. req.auth.userId is stored as assignedById.
string
required
FormTemplate.id.
string
required
The trainee. Must belong to the caller’s studio.
date
Coerced with z.coerce.date(). A future value hides the form from the trainee until that moment.
string
Trimmed, maximum 500 characters. Stored on the assignment and returned to the trainee app.
object
PDF_SIGNATURE only. A map of coach field key to string value. Ignored for every other type.
What assignForm does, in order:
1

Check the template

The template must exist in the studio and have active: true. An inactive template fails with details.reason: "FORM_INACTIVE".
2

Prepare a document to sign

For PDF_SIGNATURE the template must be PUBLISHED, have settings.document, and have at least one field the trainee fills. fieldValues is validated by validateFieldValues in signing.ts: only keys of fields with filledBy: "coach" are allowed, values are trimmed, blank values are dropped, each value is at most 500 characters, and every required coach field must have a value.
3

Create the assignment

formVersion is the template’s current version. For PDF_SIGNATURE the row also gets a signToken (16 random bytes, base64url), the validated coach values as prefill and the schema as signSchema. The link signs that frozen schema even if the coach edits the template later. notifiedAt is stamped at creation for PDF_SIGNATURE, so no push pass ever announces it.
4

Record activity

A ClientActivity row is written with type: "FORM", action: "ASSIGNED", actorType: "TRAINER", entityType: "FORM", entityId set to the template id and entityName to the template name.
5

Notify the trainee

For non PDF forms that are visible now (no future dueAt, and Client.planFrozenOn is null), the service claims the assignment by setting notifiedAt, then sends a FORM_SENT trainee push through createTraineeNotifier with vars.form set to the trainee-facing form name and data holding assignmentId and formId. If the studio has not disabled the notification and nobody was reached, muted or skipped, the claim is released so the hourly pending-form notifier can retry.
6

Raise FORM_SENT

When the form is sent now (always true for PDF_SIGNATURE), formSentHook runs raiseFormSent (tasks/form-sent.ts). It starts the studio’s FORM_SENT automation flows, or falls back to creating FORM_SENT tasks in InboxItem when no flow handles the event. Errors from the hook are swallowed. See Task automations and Tasks.
A form with a future dueAt or a frozen plan gets no push and no FORM_SENT event at this point. The hourly scheduler described in Update forms picks it up when it becomes visible. This endpoint does not deliver a signing link to the trainee. It returns signingUrl and the caller decides how to send it. Response: the assignment through presentAssignment. signToken, prefill and signSchema are never returned. signingUrl is <APP_WEB_URL>/sign/<token> for PDF_SIGNATURE and null otherwise. When APP_WEB_URL is not set the base falls back to https://app.byperform.co.il.
Errors:

GET /v1/web/forms/assignments

Lists the studio’s assignments, newest first. Auth: web lane, any role.
string
Only assignments of this trainee.
string
PENDING, COMPLETED or CANCELLED.
number
default:"1"
Positive integer.
number
default:"20"
Positive integer, maximum 500.
Response: items, total, page, pageSize. Each item is a presented assignment (no signToken, prefill or signSchema, with signingUrl) and includes the full formTemplate row.
Errors: VALIDATION.

DELETE /v1/web/forms/assignments/:id

Cancels a pending assignment. The row is deleted with deleteMany filtered on studioId and status: "PENDING". It is not moved to CANCELLED. Auth: web lane, any role.
string
required
FormAssignment.id.
For a PDF_SIGNATURE assignment this also removes the signToken, so the public signing link answers 404 from then on. Response:
Errors: NOT_FOUND (404, assignment not found or already handled) when no pending assignment with that id exists in the studio.

Responses

GET /v1/web/forms/responses

Lists submitted responses for the studio’s templates, newest first. Auth: web lane, any role.
string
Only responses to this template.
string
Only responses from this trainee.
number
default:"1"
Positive integer.
number
default:"20"
Positive integer, maximum 500.
Response: items, total, page, pageSize. Items are raw FormResponse rows with no relations included.
Errors: VALIDATION.

GET /v1/web/forms/responses/:id

Returns one response with its template and trainee. Auth: web lane, any role.
string
required
FormResponse.id.
Response: the FormResponse row plus:
  • formTemplate: id, name, type, schema.
  • client: id, name, avatarUrl.
  • aiSummary: an array of bullet strings when a summary is already cached on one of the tasks this submission raised, otherwise null. This endpoint never calls the model.
Errors: NOT_FOUND (404, form response not found).

GET /v1/web/forms/responses/:id/summary

Returns the AI summary of one submission, generating it on first request. Auth: web lane, any role.
string
required
FormResponse.id.
How getResponseSummary works:
  1. It loads up to 10 tasks (SUMMARY_TASK_LIMIT) from InboxItem that belong to the submission. summaryTaskWhere in summary-cache.ts matches tasks of type FORM_RESPONSE, FORM_FILLED, FORM_FILLED_OVERDUE, FORM_RATING_BELOW, FORM_SENT or FORM_FEEDBACK_SENT whose refId, metadata.responseId or metadata.formResponseId is the response id, or whose metadata.assignmentId is the response’s assignment.
  2. If any of those tasks has metadata.aiSummary, that cached value is returned.
  3. A PDF_SIGNATURE response is never summarized and returns null.
  4. Otherwise createFormSummarizer (ai/form-summarizer.ts, configured from OPENAI_API_KEY and OPENAI_FORM_SUMMARY_MODEL) summarizes the answers paired with schemaSnapshot (or the template schema when there is no snapshot).
  5. The bullets are written as a JSON string to metadata.aiSummary on every matched task. Write failures are ignored.
When the submission raised no task there is nowhere to cache the result, so each call asks the model again. Response:
summary is null for a signed document or when the summarizer returns nothing. Errors: NOT_FOUND (404, form response not found).

GET /v1/web/forms/responses/:id/document

Streams the signed PDF of a PDF_SIGNATURE response. The handler is coachSignedDocumentHandler in form-sign/form-sign.routes.ts. Auth: web lane, any role.
string
required
FormResponse.id.
The stored documentUrl must resolve to forms/<studioId>/signed/<24 hex chars>.pdf in the media bucket. The object is read up to 30 MB and must start with the %PDF magic bytes. Response: the raw PDF bytes with Content-Type: application/pdf, Cache-Control: private, no-store and X-Robots-Tag: noindex. Errors:

What a submission triggers

Submissions enter through the trainee app router or the public signing lane, not through the endpoints on this page. Both end in the same formSubmittedHandler (forms.wiring.ts), which is why a coach sees the same downstream effects for either path:
  • dispatchFormFilled queues the studio’s FORM_FILLED webhooks on the hook delivery queue. It runs first and is not awaited. See Automation hooks.
  • startFormFilledFlowRuns starts FORM_FILLED automation flows. When no flow handles the event, createFormFilledTask creates the legacy task instead.
  • startFormRatingBelowFlowRuns starts rating-below flows. When none handles the event, createFormRatingBelowTask creates the legacy task.
Before those hooks, an app submission also:
  • marks the assignment COMPLETED and stores schemaSnapshot on the response,
  • calls the subscription starter with INTAKE_FORM for INTAKE forms,
  • copies answers from fields flagged saveToClient onto the trainee: email, phone (through normalizePhone), israeliId, age, heightCm, birthDate, weightKg and goal on Client, a WeightEntry per weight answer, a ClientPhoto per image upload (with the field’s config.pose), and body circumferences merged into DailyMetric.measurements for the trainee’s local day. This step is best effort and never fails the submission.

Admin default form templates

DefaultFormTemplate rows are global, not per studio. The schema allows INTAKE, CHECK_IN and ONE_TIME only. All five routes are defined inline in default-forms.routes.ts with a small Prisma service, and they return raw rows with no computed fields.

GET /v1/admin/default-forms/templates

Lists every default template ordered by order ascending, then createdAt ascending. No pagination and no filters. Auth: service auth only. Response: data is an array of DefaultFormTemplate rows. Errors: none thrown by the service.

POST /v1/admin/default-forms/templates

Creates a default template. Responds with 201. Auth: service auth only.
string
required
INTAKE, CHECK_IN or ONE_TIME.
string
required
At least 1 character.
object
required
Normalized with normalizeFormSchema before saving.
string
Optional description.
boolean
default:"true"
Whether the template is offered.
number
default:"0"
Integer sort position, coerced from a string if needed.
The unique key is generated server side as custom_<timestamp>_<random>. Response: the created row. Errors: VALIDATION (422).

GET /v1/admin/default-forms/templates/:id

Returns one default template. Auth: service auth only.
string
required
DefaultFormTemplate.id.
Response: the row. Errors: NOT_FOUND (404, default form template not found).

PATCH /v1/admin/default-forms/templates/:id

Updates a default template. Only the fields you send change. key cannot be changed. Auth: service auth only.
string
required
DefaultFormTemplate.id.
string
INTAKE, CHECK_IN or ONE_TIME.
string
At least 1 character.
string | null
Send null to clear it.
object
Normalized before saving.
boolean
Whether the template is offered.
number
Integer sort position.
Response: the updated row. Errors: NOT_FOUND (404), VALIDATION (422).

DELETE /v1/admin/default-forms/templates/:id

Deletes a default template. Responds with 204 and no body. Auth: service auth only.
string
required
DefaultFormTemplate.id.
Errors: NOT_FOUND (404).