Skip to content

Custom Forms

Role names and user_group_id values 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>"}.


{
"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
{ "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.

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.


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 } } ] }

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).

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}.

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.

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}.

POST /manager/custom_forms/archive/{id}.json — {"archived": true}

An archived form cannot be started or requested and gates nothing.

POST /manager/custom_forms/delete/{id}.json

Soft delete; refused with HAS_RESPONSES once anyone has a copy.

GET /manager/custom_forms/revision/{revisionId}.json

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}.

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.

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.


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.

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).

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 a person field whose answer is that person, such as the person the form is about or a witness. Each row adds fields [{key, label, is_subject}], the person fields that name them.

Rows are shaped as in My forms.

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}.

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

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.

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.

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.

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.

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.


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.

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).

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.

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}.json with subject_model: "Flight" and an optional revision_id — the revision it was filled on. It must be a PUBLISHED revision of the same form, so a copy filled before the form was republished is kept on its own revision.
  • POST /custom_forms/sign/{id}.json with an optional offline_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 with require_password still needs the password.
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.

When a course has a MANDATORY ENROLMENT form:

  • POST /manager/trainings/students/enroll.json enrols only the students whose form is SUBMITTED or WAIVED; the others are returned in blocked: [{"user_id": 178, "forms": [{"id": "…", "name": "…"}]}].
  • POST /manager/trainings/enrollment_requests/decide/{id}/APPROVE.json answers {"result": false, "reason": "FORM_REQUIRED", "forms": […]} until it is.
  • Applications to the course are always held as PENDING for a manager, even when the course would otherwise enrol automatically.

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.