Form API Endpoints
API endpoints for managing form instances (runtime forms filled by patients/specialists).
GET /v1/forms
List forms. Org-scoped. Patients see own forms only.
Query params:
?status=pending,in_progress?type=survey?appointment_id=102?include=appointment
Response: 200
{
"data": [
{
"id": 201,
"title": "Patient Intake Survey",
"type": "survey",
"status": "in_progress",
"appointment_id": 102,
"created_at": "2025-01-15T10:30:00Z"
}
],
"meta": { "page": 1, "page_size": 25, "total": 3, "total_pages": 1 }
}POST /v1/forms
Create form instance from template. Snapshots template fields, auto-fills from patient profile.
Request:
{
"form_template_id": 10,
"user_id": 42,
"appointment_id": 102
}Response: 201
{
"data": {
"id": 201,
"title": "Patient Intake Survey",
"type": "survey",
"status": "pending",
"template_version": 3,
"fields": [...],
"values": {"field_10": "Amsterdam"},
"files": {}
}
}Business logic:
- Read form_template (fields, title, description, version)
- Snapshot fields into form.fields (immutable from this point)
- Record template_version
- Auto-fill: read custom_field_values for fields with custom_field_id set
- Pre-populate form.values with format
field_{custom_field_id}: value - Set status = "pending"
GET /v1/forms/{id}
Get form with fields, values, and files. Single query, single row.
Response: 200
{
"data": {
"id": 201,
"title": "Patient Intake Survey",
"type": "survey",
"status": "in_progress",
"template_version": 3,
"form_template_id": 10,
"appointment_id": 102,
"user_id": 42,
"fields": [
{
"custom_field_id": 10,
"label": "City",
"type": "text",
"required": false,
"private": false
},
{
"custom_field_id": 11,
"label": "Pain Level",
"type": "select",
"required": true,
"options": ["No pain", "Less pain", "Big pain"]
}
],
"values": {
"field_10": "Amsterdam",
"field_11": "Big pain"
},
"files": {
"field_20": {
"url": "s3://...",
"name": "sig.png",
"size": 45000,
"mime": "image/png"
}
},
"completed_at": null,
"signed_at": null,
"created_at": "2025-01-15T10:30:00Z"
}
}PUT /v1/forms/{id}
Save form values. Validates against field definitions. Syncs custom_field_id values to custom_field_values. Evaluates segments.
Request:
{
"values": {
"field_10": "Amsterdam",
"field_11": "Big pain"
}
}Response: 200
{
"data": {
"id": 201,
"status": "completed",
"completed_at": "2025-01-15T11:00:00Z"
}
}Business logic:
- Validate form.status is NOT "signed" (returns 409 Conflict if signed)
- Validate values against form.fields (required, types, options)
- Merge new values with existing
- For fields with custom_field_id set → upsert custom_field_values for the patient
- Evaluate segments referencing this form template
- Update status:
- Partially filled → "in_progress"
- All required fields filled → "completed"
Errors:
409 form_already_signed- Cannot update a signed form400 validation_error- Values don't match field definitions
POST /v1/forms/{id}/sign
Sign a completed form. Makes it immutable.
Request: (empty body)
Response: 200
{
"data": {
"id": 201,
"status": "signed",
"signed_at": "2025-01-15T12:00:00Z"
}
}Business logic:
- Validate form.status is "completed"
- Set status = "signed", signed_at = NOW()
- From this point: values and fields are IMMUTABLE
- Audit log records signing event
Errors:
400 form_not_completed- Form must be completed before signing409 form_already_signed- Form is already signed
DELETE /v1/forms/{id}
Delete form. Admin only. Cannot delete signed forms.
Response: 200
{
"data": {
"id": 201,
"message": "Form deleted successfully"
}
}Errors:
403 forbidden- Only admins can delete forms409 form_already_signed- Cannot delete signed forms
File Upload Endpoints
POST /v1/forms/{id}/files/{fieldKey}
Upload a file for a file field in the form.
Request:
- Content-Type:
multipart/form-data - Body: file upload
Response: 201
{
"data": {
"field_key": "field_20",
"file": {
"s3_key": "org-1/forms/201/field_20/abc123.png",
"name": "signature.png",
"size": 45000,
"mime": "image/png",
"uploaded_at": "2025-01-15T10:00:00Z"
},
"signed_url": "https://s3.amazonaws.com/...?signature=..."
}
}Business logic:
- Validate form exists and is not signed (409 if signed)
- Validate field with key exists and has type "file"
- Validate file size ≤ field.max_file_size (default: 10MB)
- Validate file MIME type in field.allowed_file_types
- Upload to S3:
{orgID}/forms/{formID}/{fieldKey}/{uuid}.{ext} - Store reference in form.files JSONB
- Return signed URL for immediate display
Errors:
409 form_already_signed- Cannot upload files to signed forms400 field_not_found- Field key doesn't exist400 field_not_file_type- Field is not a file field400 file_too_large- File exceeds max_file_size400 invalid_file_type- File MIME type not allowed
GET /v1/forms/{id}/files/{fieldKey}
Get signed URL for a file field.
Response: 302 Redirects to time-limited signed S3 URL (15-minute expiry)
Access control:
- Patient: own forms only (user_id match)
- Specialist/admin: all forms in org
- Signed URL is short-lived — no persistent URL leaks
DELETE /v1/forms/{id}/files/{fieldKey}
Delete a file from a form.
Response: 200
{
"data": {
"field_key": "field_20",
"message": "File deleted successfully"
}
}Business logic:
- Validate form is not signed (409 if signed)
- Remove S3 object
- Clear files JSONB entry
Errors:
409 form_already_signed- Cannot delete files from signed forms
Error Codes
| Code | HTTP | Description |
|---|---|---|
form_not_found | 404 | Form doesn't exist or not accessible |
form_already_signed | 409 | Cannot modify a signed form |
form_not_completed | 400 | Form must be completed before signing |
validation_error | 400 | Values don't match field definitions |
field_not_found | 400 | Field key doesn't exist in form |
field_not_file_type | 400 | Field is not a file field |
file_too_large | 400 | File exceeds max_file_size |
invalid_file_type | 400 | File MIME type not allowed |
forbidden | 403 | Insufficient permissions |