Form Builder API
Base URL: /api/v1/formbuilder
Dynamic forms: templates own sections, sections own fields, and a response is one person's filled-in copy of a template.
Paths changed in August 2026
Every resource is form-*: /form-templates, /form-sections, /form-fields. This page previously documented /templates, /sections and /fields, which have never existed since the Go package was rewritten against the live schema. If you are working from an older copy of these docs, none of its paths resolve.
Two audiences, one API
The form builder is used by staff configuring forms and by applicants filling them in, and the split is not by URL — it is per route:
- Configuration (create, update, delete, reorder, publish) is gated on
ADMIN.FORM_BUILDER. - Rendering and answering is session-only, with no permission node. An applicant holding a magic-link session carries no role, so a node gate would lock them out of their own application. Ownership is enforced per row inside the service instead, and staff-wide access to other people's responses is checked in-handler against
ADMIN.FORM_BUILDER.RESPONSES.
The exempt reads are deliberately narrow: GET /form-sections, GET /form-fields and GET /form-templates/for_program expose form structure, never anyone's answers.
Status values
A template that is live is ACTIVE, not PUBLISHED — the publish action exists, but the resulting status is ACTIVE. The form tables also carry no unique constraints, so uniqueness is not enforced by the database.
Endpoints Overview
Templates
| Method | Endpoint | Description | Gate |
|---|---|---|---|
| GET | /form-templates | List templates | ADMIN.FORM_BUILDER |
| POST | /form-templates | Create a template | ADMIN.FORM_BUILDER |
| GET | /form-templates/{templateID} | Get a template | ADMIN.FORM_BUILDER |
| PATCH | /form-templates/{templateID} | Update a template | ADMIN.FORM_BUILDER |
| DELETE | /form-templates/{templateID} | Delete a template | ADMIN.FORM_BUILDER |
| POST | /form-templates/{templateID}/publish | Make the template ACTIVE | ADMIN.FORM_BUILDER |
| POST | /form-templates/{templateID}/archive | Archive the template | ADMIN.FORM_BUILDER |
| POST | /form-templates/{templateID}/duplicate | Copy the template with its sections and fields | ADMIN.FORM_BUILDER |
| GET | /form-templates/for_program | Templates attached to a program | session only |
Sections
| Method | Endpoint | Description | Gate |
|---|---|---|---|
| GET | /form-sections | List sections (structure, no answers) | session only |
| GET | /form-sections/{sectionID} | Get a section | ADMIN.FORM_BUILDER |
| POST | /form-sections | Create a section | ADMIN.FORM_BUILDER |
| PATCH | /form-sections/{sectionID} | Update a section | ADMIN.FORM_BUILDER |
| DELETE | /form-sections/{sectionID} | Delete a section | ADMIN.FORM_BUILDER |
| POST | /form-sections/{sectionID}/reorder | Move one section | ADMIN.FORM_BUILDER |
| POST | /form-sections/bulk_reorder | Reorder several at once | ADMIN.FORM_BUILDER |
Fields
| Method | Endpoint | Description | Gate |
|---|---|---|---|
| GET | /form-fields | List fields (structure, no answers) | session only |
| GET | /form-fields/{fieldID} | Get a field | ADMIN.FORM_BUILDER |
| POST | /form-fields | Create a field | ADMIN.FORM_BUILDER |
| PATCH | /form-fields/{fieldID} | Update a field | ADMIN.FORM_BUILDER |
| DELETE | /form-fields/{fieldID} | Delete a field | ADMIN.FORM_BUILDER |
| POST | /form-fields/{fieldID}/reorder | Move one field | ADMIN.FORM_BUILDER |
| POST | /form-fields/bulk_reorder | Reorder several at once | ADMIN.FORM_BUILDER |
| POST | /form-fields/{fieldID}/set_program_visibility | Show or hide a field per program | ADMIN.FORM_BUILDER |
The GRID field type
A field of type GRID collects a table. Its column schema lives in grid_config_json on the field — not in static_options_json, which is already parsed as [{value,label}] by the SELECT/MULTI_SELECT membership check and could not carry per-column options.
{
"version": 1,
"rows": { "mode": "DYNAMIC", "min": 1, "max": 10, "add_label": "Add a course" },
"show_totals": true,
"columns": [
{ "code": "course", "label": "Course", "type": "TEXT", "required": true },
{ "code": "credits", "label": "Credits", "type": "NUMBER", "total": true },
{ "code": "grade", "label": "Grade", "type": "SELECT",
"options": [ { "value": "A", "label": "A" }, { "value": "B", "label": "B" } ] }
]
}rows.mode is DYNAMIC (the user adds rows; min/max/add_label apply) or FIXED (the admin declares them in rows.items as [{code,label}], up to 50, and the user fills the cells). Limits: 20 columns, 200 rows. Cell types are restricted to TEXT, NUMBER, DATE, SELECT and CHECKBOX.
version is validated: absent means 1, and anything else is rejected outright so a future shape change fails loudly instead of being misparsed.
Answers go in form_field_responses.value_json as an array of row objects keyed by column code — the same column MULTI_SELECT already uses:
[ { "course": "ENGL 101", "credits": 3, "grade": "A" },
{ "course": "HIST 110", "credits": 3, "grade": "B" } ]A FIXED grid's rows additionally carry "_row", naming the declared row they answer. _row is reserved — column codes may not begin with an underscore.
[] is not an answer
An empty grid serializes to a non-NULL string, so the "has this been answered?" check was taught to treat [] as unanswered, the same as an empty MULTI_SELECT. A required GRID therefore demands at least one row.
Configuration is validated at design time, when the field is created or updated — bad codes, a SELECT column with no options, min above max, fixed rows on a dynamic grid. That is what keeps submission-time validation honest: anything rejected there can never reach a response.
Program assignments
| Method | Endpoint | Description | Gate |
|---|---|---|---|
| GET | /program-form-templates | List template-to-program assignments | ADMIN.FORM_BUILDER |
| POST | /program-form-templates | Attach a template to a program | ADMIN.FORM_BUILDER |
| PATCH | /program-form-templates/{programFormID} | Update an assignment | ADMIN.FORM_BUILDER |
| DELETE | /program-form-templates/{programFormID} | Remove an assignment | ADMIN.FORM_BUILDER |
Responses
Session only. Ownership is enforced per row in the service; staff reading someone else's response are checked against ADMIN.FORM_BUILDER.RESPONSES inside the handler.
| Method | Endpoint | Description |
|---|---|---|
| GET | /form-responses | List responses the caller may see |
| POST | /form-responses | Start a response |
| GET | /form-responses/{responseID} | Get a response |
| PATCH | /form-responses/{responseID} | Update a response |
| DELETE | /form-responses/{responseID} | Delete a response |
| POST | /form-responses/{responseID}/save_progress | Save without submitting |
| POST | /form-responses/{responseID}/submit | Submit the response |
Node naming
The gates were ADMIN.FORM_BUILDER and ADMIN.FORM_BUILDER.RESPONSES until August 2026: the admissions domain was removed but those two nodes were kept, because the form builder still depended on them. Migration 209 finished the job — the screen moved to Administration → System Configuration and the nodes were renamed and re-parented in place to ADMIN.FORM_BUILDER and ADMIN.FORM_BUILDER.RESPONSES. Renaming preserved every grant, because every FK into core.permission_nodes keys on node_id, not on the code. See the unauthenticated surface.
Complete endpoint reference
Every route this domain registers, with the permission node that gates it. Generated from the route table, so it is complete by construction — the sections above explain behaviour, this one guarantees nothing is missing.
Paths are relative to /api/v1/formbuilder. A node in italics is an exemption rather than a grant: selfservice acts only on the caller's own record, public needs no session at all, and custom carries its own wrapper.
| Method | Endpoint | Permission node | Description |
|---|---|---|---|
| GET | /form-fields | selfservice | List form fields |
| POST | /form-fields | ADMIN.FORM_BUILDER | Create a form field |
| POST | /form-fields/bulk_reorder | ADMIN.FORM_BUILDER | Bulk reorder |
| DELETE | /form-fields/{fieldID} | ADMIN.FORM_BUILDER | Delete a form field |
| GET | /form-fields/{fieldID} | ADMIN.FORM_BUILDER | Get one form field |
| PATCH | /form-fields/{fieldID} | ADMIN.FORM_BUILDER | Update a form field |
| POST | /form-fields/{fieldID}/reorder | ADMIN.FORM_BUILDER | Reorder for a form field |
| POST | /form-fields/{fieldID}/set_program_visibility | ADMIN.FORM_BUILDER | Set program visibility for a form field |
| GET | /form-responses | selfservice | List form responses |
| POST | /form-responses | selfservice | Create a form respons |
| DELETE | /form-responses/{responseID} | selfservice | Delete a form respons |
| GET | /form-responses/{responseID} | selfservice | Get one form respons |
| PATCH | /form-responses/{responseID} | selfservice | Update a form respons |
| POST | /form-responses/{responseID}/save_progress | selfservice | Add save progress to a form respons |
| POST | /form-responses/{responseID}/submit | selfservice | Submit for a form respons |
| GET | /form-sections | selfservice | List form sections |
| POST | /form-sections | ADMIN.FORM_BUILDER | Create a form section |
| POST | /form-sections/bulk_reorder | ADMIN.FORM_BUILDER | Bulk reorder |
| DELETE | /form-sections/{sectionID} | ADMIN.FORM_BUILDER | Delete a form section |
| GET | /form-sections/{sectionID} | ADMIN.FORM_BUILDER | Get one form section |
| PATCH | /form-sections/{sectionID} | ADMIN.FORM_BUILDER | Update a form section |
| POST | /form-sections/{sectionID}/reorder | ADMIN.FORM_BUILDER | Reorder for a form section |
| GET | /form-templates | ADMIN.FORM_BUILDER | List form templates |
| POST | /form-templates | ADMIN.FORM_BUILDER | Create a form template |
| GET | /form-templates/for_program | selfservice | Get for program |
| DELETE | /form-templates/{templateID} | ADMIN.FORM_BUILDER | Delete a form template |
| GET | /form-templates/{templateID} | ADMIN.FORM_BUILDER | Get one form template |
| PATCH | /form-templates/{templateID} | ADMIN.FORM_BUILDER | Update a form template |
| POST | /form-templates/{templateID}/archive | ADMIN.FORM_BUILDER | Archive for a form template |
| POST | /form-templates/{templateID}/duplicate | ADMIN.FORM_BUILDER | Duplicate for a form template |
| POST | /form-templates/{templateID}/publish | ADMIN.FORM_BUILDER | Publish for a form template |
| GET | /program-form-templates | ADMIN.FORM_BUILDER | List program form templates |
| POST | /program-form-templates | ADMIN.FORM_BUILDER | Create a program form template |
| DELETE | /program-form-templates/{programFormID} | ADMIN.FORM_BUILDER | Delete a program form template |
| PATCH | /program-form-templates/{programFormID} | ADMIN.FORM_BUILDER | Update a program form template |