Custom Forms
Role names and
user_group_idvalues are listed in User groups.
Company-built forms, their revisions, and the filled copies (“responses”). Every endpoint:
- needs the company’s plan to include forms (Premium or Unlimited) — otherwise
403“Custom forms are available on the Premium and Unlimited plans”; - is scoped to the caller’s company — a record of another company answers
404, the same as a missing one.
/manager/custom_forms/* endpoints require user_group_id 1, 100, 105 or 110. The other endpoints decide per form and per response (see Access rules).
Business refusals answer 200 with {"result": false, "code": "<CODE>"}.
Data shapes
Section titled “Data shapes”Schema (one revision)
Section titled “Schema (one revision)”{ "sections": [ { "key": "s_cand01", "title": "Pre-entry requirements", "condition": null, "filler_groups": null, "fields": [ { "key": "f_ppl001", "type": "yes_no", "label": "An applicant for a modular IR(A) course shall be the holder of a PPL(A)…", "help": "", "required": true, "evidence": true, "condition": null, "options": [], "unit": "", "hhmm": false } ] } ]}| Property | Notes |
|---|---|
key |
^[a-z]_[a-z0-9_]{1,24}$, unique across sections and fields |
type |
text_block, checkbox, yes_no, choice, text, textarea, number, date, datetime, photo, pdf, person |
condition |
null or {"field": "<earlier field key>", "equals": <value>}. The source must be an earlier checkbox (true/false), yes_no ("YES"/"NO") or choice (option key) field |
filler_groups |
null (part of the main signature) or user_group_ids that sign this section separately |
options |
choice only: [{"key": "o_dual01", "label": "Dual"}] |
unit, hhmm |
number only |
user_groups |
person only: user_group_ids whose active users may be chosen; [] = everyone in the company |
is_subject |
person only: the chosen user becomes the copy’s subject (subject_user_id). At most one per form, always required, never conditional (field or section, SUBJECT_CONDITIONAL), never in a separately signed section (SUBJECT_SIGNED_SEPARATELY); a second one is SUBJECT_TWICE |
Answers
Section titled “Answers”{ "f_ppl001": { "value": "YES", "files": ["<upload id>"] } }value by type: checkbox bool · yes_no "YES"/"NO" · choice option key · text/textarea string · number number · date "YYYY-MM-DD" · datetime unix seconds · person user id · photo/pdf null (the files are the answer). files holds upload ids filed with model=FormResponse, foreign_key=<response id> (see Uploads). Answers to hidden fields and unknown keys are dropped.
Signing units
Section titled “Signing units”A form signs in units: form (every section without filler_groups) plus one unit per section with filler_groups. A response is SUBMITTED when every unit with at least one shown section is signed.
Manager endpoints
Section titled “Manager endpoints”List forms
Section titled “List forms”GET /manager/custom_forms/index.json
{ "forms": [ { "id": "…", "name": "Pre-entry IR(A)", "status": "PUBLISHED", "current_revision_number": 0, "triggers": [ { "type": "ENROLMENT", "mode": "MANDATORY" } ], "counts": { "REQUESTED": 2, "DRAFT": 1, "SUBMITTED": 14 } } ] }View a form
Section titled “View a form”GET /manager/custom_forms/view/{id}.json
Returns form, groups ({"FILL": [], "VIEW": [], "OPEN": []}), triggers, draft (unpublished edits or null), current (the published revision new responses use) and revisions (published history: number, labels, published, published_by_name, content_hash).
Create or update a form
Section titled “Create or update a form”POST /manager/custom_forms/save.json
{ "CustomForm": { "id": "<omit to create>", "name": "Pre-entry IR(A)", "description": "", "fill_mode": "GROUPS", "require_password": true, "retention_months": 60 }, "groups": { "FILL": [135], "VIEW": [150], "OPEN": [] }, "triggers": [ { "type": "ENROLMENT", "mode": "MANDATORY", "scope": { "training_ids": ["<course id>"] } } ]}fill_mode: SUBJECT, GROUPS, SUBJECT_OR_GROUPS. Groups and triggers are replaced. Only ENROLMENT triggers are accepted in this release; course ids outside the company are dropped; an empty training_ids means every course. Response: {"result": true, "form_id": "…", "errors": null}.
Save the draft revision
Section titled “Save the draft revision”POST /manager/custom_forms/save_draft/{id}.json
{ "schema": { "sections": [] }, "label_issue": "01", "label_revision": "00", "label_effective": "2026-03-05" }Validated before saving; on error nothing is saved and errors lists {"path": "sections.0.fields.2.label", "code": "LABEL_REQUIRED"}. Codes: SECTION_TITLE_REQUIRED, BAD_KEY, DUPLICATE_KEY, BAD_TYPE, LABEL_REQUIRED, CHOICE_NEEDS_OPTIONS, CONDITION_SOURCE_MISSING, CONDITION_SOURCE_LATER, CONDITION_SOURCE_TYPE, CONDITION_VALUE_INVALID, UNKNOWN_GROUP, TOO_LARGE, BAD_DATE.
Publish
Section titled “Publish”POST /manager/custom_forms/publish/{id}.json
The draft becomes the next numbered revision (first = 0) and the form’s current revision. Responses already started keep their revision. Refusals: NOTHING_TO_PUBLISH, INVALID (+ errors, including NO_SECTIONS). Success: {"result": true, "number": 1}.
Archive / restore
Section titled “Archive / restore”POST /manager/custom_forms/archive/{id}.json — {"archived": true}
An archived form cannot be started or requested and gates nothing.
Delete
Section titled “Delete”POST /manager/custom_forms/delete/{id}.json
Soft delete; refused with HAS_RESPONSES once anyone has a copy.
One revision
Section titled “One revision”GET /manager/custom_forms/revision/{revisionId}.json
Responses of a form
Section titled “Responses of a form”GET /manager/custom_forms/responses/{formId}.json?status=&q=&page=
50 per page, newest change first. q searches the person’s name. Each row: id, status, origin, subject_user {id, name}, subject_label, subject_ref, answers, revision_id, created, submitted, due, missing [{key, label}] (required answers still missing on that copy’s own revision). Plus people {user id: name} for every user a person answer names, and paging {page, limit, count, pageCount}.
Send a form to people
Section titled “Send a form to people”POST /manager/custom_forms/request/{formId}.json
{ "user_ids": [178, 306], "pilot_group_ids": ["<group id>"], "due": 1793800000, "note": "Please fill in before Friday" }One REQUESTED copy per active user of the company (pilot groups expanded), each notified. Users who already have an open copy are returned in skipped. Refusal: NOT_PUBLISHED.
Waive a mandatory form
Section titled “Waive a mandatory form”POST /manager/custom_forms/waive/{formId}.json
{ "subject_model": "Training", "subject_id": "<course id>", "subject_user_id": 178, "reason": "Holds CPL(A), confirmed by HT" }Records a WAIVED copy, which satisfies the enrolment gate like a submitted one. Refusals: REASON_REQUIRED, NOT_PUBLISHED.
Filling endpoints
Section titled “Filling endpoints”My forms
Section titled “My forms”GET /custom_forms/mine.json
pending (copies with a unit the caller still has to sign, due first), available (forms the caller may open at will), submitted (the caller’s 50 latest finished copies).
Each row: id, form {id, name}, status, due, subject_label, created, modified, submitted.
Latest forms (dashboard)
Section titled “Latest forms (dashboard)”GET /custom_forms/recent.json?limit=5
The latest copies the caller may see, most recently changed first. limit is 1–20 (default 5). Managers (user_group_id ≤ 110) get every copy in the company; anyone else gets the copies they started, signed or are about, and the copies of forms their group fills, views or signs a section of. Response: {"responses": [row…]}, each row as in My forms plus to_fill (true when a part of the copy is still the caller’s to fill and sign).
Forms of a person
Section titled “Forms of a person”GET /custom_forms/person/{userId}.json
The forms box on a person’s page. The user must belong to the caller’s company, otherwise 404. Only the copies the caller may see are listed, newest change first, up to 50 in each list:
filled: copies the person filled in. That means they signed a part of it, or started it themselves. A copy that was only requested from them counts once they sign it.named: copies with apersonfield whose answer is that person, such as the person the form is about or a witness. Each row addsfields [{key, label, is_subject}], the person fields that name them.
Rows are shaped as in My forms.
Start a response
Section titled “Start a response”POST /custom_forms/start/{formId}.json
{}…starts one about the caller (the form must be open to their group). For a course pre-entry form:
{ "subject_model": "Training", "subject_id": "<course id>", "subject_user_id": 178, "trigger_id": "<ENROLMENT trigger id>" }About another person (managers, user_group_id ≤ 110, and the form’s FILL groups only; the person must belong to the company). No notification is sent, unlike sending a form:
{ "subject_model": "User", "subject_user_id": 178 }Returns the open copy for that form and subject if there is one, otherwise a new DRAFT on the current revision. Response: {"result": true, …bundle}.
Read a response
Section titled “Read a response”GET /custom_forms/response/{id}.json
The bundle:
| Key | Content |
|---|---|
people |
per person field key: [{id, name}] the caller may choose (only while they can still fill that field), plus whoever it names now |
response |
id, status, origin, subject, subject_label, subject_ref, answers, due, created, submitted, submitted_by |
form |
id, name, description, require_password, retention_months |
revision |
the revision the copy is pinned to, with its schema and labels |
units, unit_groups |
signing units and who signs each |
signatures |
per unit: user_id, name, group, time |
fillable_units |
units the caller may still write and sign |
files |
the copy’s uploads with signed URLs |
can_skip, can_reopen, can_delete |
|
history |
managers only: SIGN, SUBMIT, REOPEN, WAIVE, SKIP, REQUEST with user, time and reason |
subject_ref is the record the copy is about, or null (a flight not saved yet, a public copy):
type |
id |
|---|---|
FLIGHT |
flight id |
BOOKING |
booking (schedule) id |
ENROLMENT |
the person’s enrolment (trainings_users.id) in the course, plus course_id |
COURSE |
course id, when the person has no enrolment in it yet |
PERSON |
user id |
Save answers
Section titled “Save answers”POST /custom_forms/save/{id}.json — {"answers": {…}}
Only the fields of the caller’s unsigned units are written; a field of those units missing from answers is cleared. Refusals: LOCKED, NOT_YOURS, SIGNED_UNIT_CHANGED, PERSON_NOT_ALLOWED (a person value outside the field’s list; a value the copy already held is kept even if that user has since left the list).
On a copy about a person (subject_model User), a new value in the is_subject field moves the copy to that person (subject_id, subject_user_id). Allowed only to callers who may start a copy about them (managers and the form’s FILL groups); anyone else gets NOT_YOURS. Copies about a flight, booking or course keep their subject. A copy started about another person, by start or by request, arrives with that field already filled.
Sign a unit
Section titled “Sign a unit”POST /custom_forms/sign/{id}.json
{ "unit": "form", "answers": { … }, "password": "<only when the form requires it>" }Saves, then signs: the unit must be the caller’s, unsigned, shown, and complete. Refusals: LOCKED, NOT_YOURS, SIGNED_UNIT_CHANGED, UNIT_NOT_SHOWN, MISSING (+ missing keys; what was typed is kept). A wrong password answers 400. When the last unit is signed the copy becomes SUBMITTED ("submitted": true) and whoever requested it is notified.
POST /custom_forms/skip/{id}.json
Only a copy started from a SKIPPABLE trigger. Refusal: NOT_SKIPPABLE.
Reopen
Section titled “Reopen”POST /custom_forms/reopen/{id}.json — {"reason": "…"}
user_group_id ≤ 110, SUBMITTED copies only. Voids every signature, keeps the previous answers in the history, returns the copy to DRAFT and notifies its signers and the person concerned. Refusal: REASON_REQUIRED.
Delete a draft
Section titled “Delete a draft”POST /custom_forms/delete_response/{id}.json
Deletes a DRAFT copy with its signatures, history and files. Allowed for the user who created it while no unit is signed, and for user_group_id ≤ 110 at any time. Every other status, and anyone else, gets {"result": false, "code": "NOT_DELETABLE"} (or 404 when the caller cannot see the copy). The bundle’s can_delete says whether the caller may.
Forms of a course for one person
Section titled “Forms of a course for one person”GET /custom_forms/for_subject.json?subject_model=Training&subject_id=<course id>&user_id=<user id>
offered: the course’s pre-entry forms still to fill (or skip) for that person that the caller may start — id, name, mode, trigger_id, open response. responses: every copy about that person and course the caller may see.
Flight forms
Section titled “Flight forms”Trigger types PREFLIGHT, POSTFLIGHT, BOOKING (scope flight_type_ids, aircraft_ids), FIRST_FLIGHT_OF_DAY (aircraft_ids) and SOLO (training_ids; applies when a training mission on the flight has require_authorization > 0). Stages: PRE = preflight, first flight of the day, solo; POST = post-flight; BOOK = booking creation.
A scope id that does not belong to the company (flight type, aircraft, course) makes manager_save answer {"result": false, "errors": {"triggers": [{"path": "triggers.0.scope.aircraft_ids", "code": "SCOPE_NOT_FOUND"}]}}; nothing is saved. CustomForm.fill_seat: PIC (default), SIC, SUPERVISOR, ANY_CREW.
Forms of a flight
Section titled “Forms of a flight”GET /custom_forms/flight_forms.json?stage=PRE|POST|BOOK&schedule_id= · &flight_id= · &token=<uuid>&aircraft_id=&flight_type_id=&date=YYYY-MM-DD&mission_ids[]=
offered (each with type, mode, trigger_id, open response) and responses (this stage’s copies) for a booking, a flight, or a flight not saved yet (a client-generated uuid token). Staff above user_group_id 145 only for flights they crew. stage=BOOK needs a token (the booking dialog’s).
Starting a flight form
Section titled “Starting a flight form”POST /custom_forms/start/{formId}.json with subject_model Schedule | Flight | Pending, subject_id (booking id, flight id or token) and the trigger_id from flight_forms; for Pending also aircraft_id, flight_type_id, date, mission_ids. Allowed for the seat holder (any crew for ANY_CREW), the form’s FILL groups and managers. Each stage keeps its own copy.
Offline
Section titled “Offline”GET /custom_forms/flight_definitions.json
Every PUBLISHED form of the company with a PREFLIGHT, FIRST_FLIGHT_OF_DAY, SOLO or POSTFLIGHT trigger, for clients that fill flight forms without signal (Premium and Unlimited plans; readable by managers and by pilots, students and instructors so it can be cached on their devices — it carries form definitions only, never answers):
{ "definitions": [ { "form": { "id": "…", "name": "…", "description": "…", "fill_mode": "SUBJECT", "fill_seat": "PIC", "require_password": false }, "revision": { "id": "…", "schema": { … }, … }, "groups": { "OPEN": [], "FILL": [], "VIEW": [] }, "triggers": [ { "id": "…", "type": "POSTFLIGHT", "mode": "MANDATORY", "scope": { "aircraft_ids": ["12"] } } ], "people": { "f_wit001": [ { "id": 178, "name": "Antonio Oreilly" } ] }} ] }people lists, per person field, who it may name, so the field can be filled without signal.
A copy filled offline is replayed after its flight is created:
POST /custom_forms/start/{formId}.jsonwithsubject_model: "Flight"and an optionalrevision_id— the revision it was filled on. It must be aPUBLISHEDrevision of the same form, so a copy filled before the form was republished is kept on its own revision.POST /custom_forms/sign/{id}.jsonwith an optionaloffline_signed_at(unix seconds). Values up to 5 minutes in the future and no older than 30 days are written into the copy’s history (“signed offline at … UTC”); others are ignored. The signature itself is dated when it reaches the server. A form withrequire_passwordstill needs the password.
Where they are enforced
Section titled “Where they are enforced”| Endpoint | Stage | Subject | Refusal |
|---|---|---|---|
POST /schedules/flight.json (dispatch a booking) |
PRE | the booking | {"result": false, "code": "FORM_REQUIRED", "message", "forms"} |
POST /flights/dispatch/{id}.json |
PRE | the flight | same |
POST /flights/create.json — new flight, no client_id |
PRE | data[form_token] |
same |
POST /flights/confirm/{id}.json |
POST | the flight | {"result": {"code": "FORM_REQUIRED", "message", "forms", …}} |
POST /schedules/edit.json — new booking made by the caller for themselves |
BOOK | data[form_token] |
{"result": false, "code": "FORM_REQUIRED", "message", "forms"} |
A create carrying client_id (offline replay) is never refused. A booking counts as made for oneself when self_schedule is 1 or the caller is its pic_id, sic_id or supervisor_id; edits and bookings made for others are never asked. Every row of one booking dialog (repeat dates, legs) sends the same form_token: the first saved booking takes the copies, and the next rows are covered by them. On dispatch the booking’s copies move to the new flight; on create the token’s copies (the caller’s) move to the new flight. manager_waive accepts subject_model Schedule | Flight with the trigger_id it answers.
Course enrolment
Section titled “Course enrolment”When a course has a MANDATORY ENROLMENT form:
POST /manager/trainings/students/enroll.jsonenrols only the students whose form isSUBMITTEDorWAIVED; the others are returned inblocked: [{"user_id": 178, "forms": [{"id": "…", "name": "…"}]}].POST /manager/trainings/enrollment_requests/decide/{id}/APPROVE.jsonanswers{"result": false, "reason": "FORM_REQUIRED", "forms": […]}until it is.- Applications to the course are always held as
PENDINGfor a manager, even when the course would otherwise enrol automatically.
Access rules
Section titled “Access rules”| Who | May |
|---|---|
Managers (user_group_id ≤ 110) |
see every copy; fill the form unit on anyone’s behalf; reopen; waive; delete any DRAFT |
| The creator of a copy | delete it while it is a DRAFT with no signed unit |
| The person the copy is about | see it once it is SUBMITTED, SKIPPED or WAIVED; while it is REQUESTED or DRAFT, only when fill_mode is SUBJECT or SUBJECT_OR_GROUPS (a GROUPS copy staff fill about them, such as a disciplinary record, returns 404 to them until it is signed); fill the form unit when fill_mode is SUBJECT or SUBJECT_OR_GROUPS |
FILL groups |
see copies of the form; start one about anyone; fill the form unit when fill_mode is GROUPS or SUBJECT_OR_GROUPS |
A section’s filler_groups |
see the copy; fill and sign only that section (managers cannot sign it for them) |
VIEW groups, signers, the creator |
see it |
OPEN groups |
start a copy about themselves at any time |
Files with model=FormResponse follow the same rules on every uploads endpoint: readable by whoever may see the copy, added or removed only while the caller can still fill an unsigned unit, and never moved to another record.