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-idandx-user-idare required. Without them the request fails withUNAUTHORIZED(401,missing web user context).x-user-roleis parsed intoOWNER,HEAD_COACH,SUB_COACHorTRAINEE. Any other value, or no header, becomesSUB_COACH.- The studio is resolved from
x-studio-id(Studio.externalOrgId) and created if it does not exist. AnOWNERgets aHEAD_COACHrow inCoachon first contact. - The result is
req.authwithstudioId,userIdandrole.
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:
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 theFormTemplate row plus two computed fields from enrichTemplate:
fieldCount: number of fields that are not display only.responseCount: number ofFormResponserows 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.string
Case-insensitive
contains match on name.number
default:"1"
Positive integer.
number
default:"20"
Positive integer, maximum 500.
data holds items (templates in the shape above), total, page and pageSize.
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.
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.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.
updateTemplate in forms.service.ts):
- A
PDF_SIGNATUREtemplate cannot change type, and no other template can becomePDF_SIGNATURE. - A template cannot become
ONE_TIMEwhile anyProductorClientstill references it throughonboardingFormIdorupdateFormId. - Schedule fields are only written when the resulting type is
CHECK_IN. Switching a template toCHECK_INwithout acadencesetsWEEKLY. Switching away fromCHECK_INclearscadence,sendWeekdayandsendDayOfMonth. - Every request that sends
status: "PUBLISHED"incrementsversionby 1, including when the template is already published. New assignments record the version at send time inFormAssignment.formVersion. - For
PDF_SIGNATURE, a newschematriggers the document check only whensettings.document.urlorpageCountchanged.
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.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.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.
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.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.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.
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.
items, total, page, pageSize. Each item is a presented assignment (no signToken, prefill or signSchema, with signingUrl) and includes the full formTemplate row.
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.PDF_SIGNATURE assignment this also removes the signToken, so the public signing link answers 404 from then on.
Response:
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.
items, total, page, pageSize. Items are raw FormResponse rows with no relations included.
VALIDATION.
GET /v1/web/forms/responses/:id
Returns one response with its template and trainee.
Auth: web lane, any role.
string
required
FormResponse.id.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, otherwisenull. This endpoint never calls the model.
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.getResponseSummary works:
- It loads up to 10 tasks (
SUMMARY_TASK_LIMIT) fromInboxItemthat belong to the submission.summaryTaskWhereinsummary-cache.tsmatches tasks of typeFORM_RESPONSE,FORM_FILLED,FORM_FILLED_OVERDUE,FORM_RATING_BELOW,FORM_SENTorFORM_FEEDBACK_SENTwhoserefId,metadata.responseIdormetadata.formResponseIdis the response id, or whosemetadata.assignmentIdis the response’s assignment. - If any of those tasks has
metadata.aiSummary, that cached value is returned. - A
PDF_SIGNATUREresponse is never summarized and returnsnull. - Otherwise
createFormSummarizer(ai/form-summarizer.ts, configured fromOPENAI_API_KEYandOPENAI_FORM_SUMMARY_MODEL) summarizes the answers paired withschemaSnapshot(or the template schema when there is no snapshot). - The bullets are written as a JSON string to
metadata.aiSummaryon every matched task. Write failures are ignored.
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.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 sameformSubmittedHandler (forms.wiring.ts), which is why a coach sees the same downstream effects for either path:
dispatchFormFilledqueues the studio’sFORM_FILLEDwebhooks on the hook delivery queue. It runs first and is not awaited. See Automation hooks.startFormFilledFlowRunsstartsFORM_FILLEDautomation flows. When no flow handles the event,createFormFilledTaskcreates the legacy task instead.startFormRatingBelowFlowRunsstarts rating-below flows. When none handles the event,createFormRatingBelowTaskcreates the legacy task.
- marks the assignment
COMPLETEDand storesschemaSnapshoton the response, - calls the subscription starter with
INTAKE_FORMforINTAKEforms, - copies answers from fields flagged
saveToClientonto the trainee:email,phone(throughnormalizePhone),israeliId,age,heightCm,birthDate,weightKgandgoalonClient, aWeightEntryper weight answer, aClientPhotoper image upload (with the field’sconfig.pose), and body circumferences merged intoDailyMetric.measurementsfor 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.
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.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.
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.NOT_FOUND (404).