- Add VitePress pages for AVAILABILITY_PICKER and SECTION_PRIORITY
and a TAG_PICKER configuration note. Wire them into the organisator
sidebar under a new Formulieren section alongside the existing
"Wat is een formulier" page.
- BACKLOG.md: nuance FORM-05 — the stub-shaped behaviour for public
event_registration submissions is already shipping via the existing
TriggerPersonIdentityMatchOnFormSubmit listener (writes 'pending').
The real work (PersonIdentityService::detectMatchesByValues + an
extra branch in resolveStatus) is what remains. Added a done entry
for S3a PR 2 to the Opgeloste items list.
- API.md: add VALIDATION_FAILED to the public-form error code table
and document the SECTION_PRIORITY shape error messages (Dutch copy
served under errors."values.{slug}").
- COPY_CATALOGUE.md: new S3a PR 2 section capturing the seeder
help_text, the IdentityMatchBanner copy (clearly marking the
backend message as authoritative), all empty/error state copy for
the three new components, and the SECTION_PRIORITY shape error
strings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
52 KiB
Crewli API Contract
Base path: /api/v1/
Auth: Bearer token (Sanctum) — token delivered via httpOnly cookie, never in the JSON response body. See /dev-docs/AUTH_ARCHITECTURE.md for full details.
Auth
POST /auth/login— returns user data in JSON body. The Sanctum bearer token is set as an httpOnly cookie viaSet-Cookieheader (not included in response body).POST /auth/logoutGET /auth/mePOST /auth/refresh— rotates the Sanctum token: revokes current token, creates new one, sets new httpOnly cookie. Returns current user data.POST /auth/forgot-password— request password reset (public, rate-limited). Body:{ email, app: "app"|"portal"|"admin" }. Always returns 200 (no email enumeration).POST /auth/reset-password— reset password with token (public). Body:{ token, email, password, password_confirmation }.
MFA (Multi-Factor Authentication)
Login flow with MFA
When MFA is enabled for a user, login becomes a two-step process:
POST /auth/login— if MFA is active, returns{ mfa_required: true, mfa_session_token, methods, preferred_method, expires_in }instead of the auth tokenPOST /auth/mfa/verify— submit the MFA code with the session token to complete login
If the device is trusted (via X-Device-Fingerprint header), MFA is bypassed and login proceeds normally.
MFA verification during login (public, rate-limited)
POST /auth/mfa/verify— verify MFA code during login. Body:{ mfa_session_token, code, method: "totp"|"email"|"backup_code", trust_device?: bool, device_fingerprint?: string, device_name?: string }. Returns user data + auth cookie on success.POST /auth/mfa/email/send— send/resend email verification code during login. Body:{ mfa_session_token }. Rate-limited: 1 code per 60 seconds.
MFA setup and management (authenticated)
POST /auth/mfa/setup/totp— start TOTP setup, returns{ secret, qr_code_url, provisioning_uri }POST /auth/mfa/setup/totp/confirm— confirm TOTP with first code. Body:{ code }. Returns{ mfa_enabled, method, backup_codes[] }POST /auth/mfa/setup/email— start email MFA setup (sends verification code)POST /auth/mfa/setup/email/confirm— confirm email MFA. Body:{ code }. Returns{ mfa_enabled, method, backup_codes[] }POST /auth/mfa/disable— disable MFA. Body:{ code, method: "totp"|"backup_code" }. Requires valid verification code.POST /auth/mfa/backup-codes— regenerate backup codes. Body:{ code }. Requires valid TOTP code.GET /auth/mfa/status— current MFA status:{ mfa_enabled, method, confirmed_at, backup_codes_remaining, is_required }
Trusted devices (authenticated)
GET /auth/trusted-devices— list active trusted devicesDELETE /auth/trusted-devices/{id}— revoke a specific deviceDELETE /auth/trusted-devices— revoke all devices
Admin MFA management (super_admin)
POST /admin/users/{user}/reset-mfa— force-disable MFA for a user. Activity logged.
Account Management (authenticated)
POST /me/change-password— change own password. Body:{ current_password, password, password_confirmation }. Revokes other sessions.POST /me/change-email— request email change (sends verification to new address). Body:{ new_email, password, app: "app"|"portal"|"admin" }.POST /verify-email-change— verify email change token (public). Body:{ token }. Revokes all sessions.
Organisations
GET /organisations— list (super admin)POST /organisations— createGET /organisations/{org}— showPUT /organisations/{org}— updateGET /organisations/{org}/members— membersPOST /organisations/{org}/invite— invite userPOST /organisations/{org}/members/{user}/change-email— admin initiates email change for a member (org_admin only). Body:{ new_email }.
Events
GET /organisations/{org}/events— list (top-level only by default)GET /organisations/{org}/events?include_children=true— include sub-events nested in responseGET /organisations/{org}/events?type=festival— filter by event_type (festival|series|event)POST /organisations/{org}/events— create (supportsparent_event_idfor sub-events)GET /organisations/{org}/events/{event}— detail (includes children and parent if loaded)PUT /organisations/{org}/events/{event}— update (does NOT acceptstatus— use transition endpoint)POST /organisations/{org}/events/{event}/transition— change event status via state machine (see below)GET /organisations/{org}/events/{event}/children— list sub-events of a festival/series
Event Status Transitions
POST /organisations/{org}/events/{event}/transition
Body: { "status": "published" }
Enforces a state machine: only valid forward (and select backward) transitions are allowed.
Returns 422 with errors, current_status, requested_status, and allowed_transitions when the transition is invalid or prerequisites are missing.
Prerequisites checked:
→ published: name, start_date, end_date required→ registration_open: at least one time slot and one section required
Festival cascade: Transitioning a festival parent to showday, teardown, or closed automatically cascades to all children in an earlier status.
EventResource includes allowed_transitions (array of valid next statuses) so the frontend knows which buttons to show.
Event Stats
GET /organisations/{org}/events/{event}/stats— aggregate dashboard counts for the event
Response
{
"data": {
"persons_total": 142,
"persons_approved": 98,
"persons_pending": 31,
"persons_rejected": 8,
"persons_other": 5,
"persons_approved_without_shift": 23,
"pending_identity_matches": 3,
"shifts_total": 45,
"shifts_filled": 38,
"shifts_understaffed": 7
}
}
Crowd Types
GET /organisations/{org}/crowd-typesPOST /organisations/{org}/crowd-typesPUT /organisations/{org}/crowd-types/{type}DELETE /organisations/{org}/crowd-types/{type}
Companies
GET /organisations/{org}/companiesPOST /organisations/{org}/companiesPUT /organisations/{org}/companies/{company}DELETE /organisations/{org}/companies/{company}
Section Categories
GET /organisations/{org}/section-categories— distinct categories used across the organisation's events (for autocomplete). Returns{ "data": ["Bar", "Podium", ...] }
Festival Sections
GET /organisations/{org}/events/{event}/sectionsPOST /organisations/{org}/events/{event}/sectionsPUT /organisations/{org}/events/{event}/sections/{section}DELETE /organisations/{org}/events/{event}/sections/{section}POST /organisations/{org}/events/{event}/sections/reorder
Festival context:
{event}can be a festival parent or a sub-event. On a festival parent, sections are for operational planning (build-up, teardown). For sub-events,GETautomatically includescross_eventsections from the parent festival. Shifts on cross_event sections must use the parent festival's event_id in API calls, since the section'sevent_idpoints to the parent.
Registration Settings (Festival-level bulk management)
GET /organisations/{org}/events/{event}/sections/registration-settings— returns unique section names across the festival with registration visibility, description, and countsPUT /organisations/{org}/events/{event}/sections/registration-settings— bulk update registration visibility for a section name across all instances in the festival
GET Response
{
"data": [
{
"name": "Hoofdpodium Bar",
"category": "Bar",
"icon": "tabler-beer",
"show_in_registration": true,
"registration_description": "Tap bier en drankjes voor festivalgangers",
"section_count": 3,
"section_ids": ["ulid1", "ulid2", "ulid3"]
}
]
}
PUT Body
{
"name": "Hoofdpodium Bar",
"show_in_registration": true,
"registration_description": "Tap bier en drankjes voor festivalgangers"
}
Returns the full updated registration-settings response. Creates activity log section.registration_settings_updated.
Auth: org_admin or event_manager on the event's organisation.
Time Slots
GET /organisations/{org}/events/{event}/time-slotsPOST /organisations/{org}/events/{event}/time-slotsPUT /organisations/{org}/events/{event}/time-slots/{timeSlot}DELETE /organisations/{org}/events/{event}/time-slots/{timeSlot}
Festival context:
{event}can be a festival parent or a sub-event. Festival-level time slots (operational: build-up, teardown, transitions) are separate from sub-event time slots (program-specific).
GET /organisations/{org}/events/{event}/time-slotsreturns only the specified event's own time slots by default. For sub-events, pass?include_parent=trueto also include the parent festival's time slots — each time slot is marked with asourcefield (sub_eventorfestival) and includesevent_namefor display grouping. This parameter has no effect on festivals or flat events.For festivals (events with sub-events), pass
?include_children=trueto include all sub-event time slots. Each time slot is marked withsource(ownor the sub-event'sevent_id) andevent_name. Used by cross_event section shift dialogs that need access to all time slots across the festival. This parameter has no effect on sub-events or flat events.Shifts on sub-event sections may reference parent festival time slots (e.g. for build-up shifts). Shifts on cross_event sections may reference any time slot from the festival or its sub-events. The
time_slot_idvalidation accepts time slots accordingly.
Shifts
GET /organisations/{org}/events/{event}/sections/{section}/shiftsPOST /organisations/{org}/events/{event}/sections/{section}/shiftsPUT /organisations/{org}/events/{event}/sections/{section}/shifts/{shift}DELETE /organisations/{org}/events/{event}/sections/{section}/shifts/{shift}POST /organisations/{org}/events/{event}/sections/{section}/shifts/{shift}/assignPOST /organisations/{org}/events/{event}/sections/{section}/shifts/{shift}/claim
Festival context: When managing shifts on a
cross_eventsection, the{event}in the URL must be the parent festival's ID (matchingsection.event_id), not the sub-event's ID.
Shift Assignments
GET /organisations/{org}/events/{event}/shift-assignments— list assignments for event (paginated, 50/page)POST /organisations/{org}/events/{event}/shift-assignments/{shiftAssignment}/approve— approve pending assignmentPOST /organisations/{org}/events/{event}/shift-assignments/{shiftAssignment}/reject— reject pending assignmentPOST /organisations/{org}/events/{event}/shift-assignments/{shiftAssignment}/cancel— cancel assignmentPOST /organisations/{org}/events/{event}/shift-assignments/bulk-approve— bulk approve multiple assignmentsGET /organisations/{org}/events/{event}/shifts/{shift}/assignable-persons— list approved persons with availability status
Assignable Persons
GET /organisations/{org}/events/{event}/shifts/{shift}/assignable-persons
Returns all approved persons for the event with availability status for this shift's time slot. Persons are sorted: available first, then unavailable (conflict), then already assigned.
{
"data": [
{
"id": "ulid",
"first_name": "Jan",
"last_name": "de Vries",
"full_name": "Jan de Vries",
"email": "jan@gmail.com",
"status": "approved",
"crowd_type": { "id": "ulid", "name": "Vrijwilliger", "system_type": "VOLUNTEER" },
"is_available": true,
"already_assigned": false,
"conflict": null
},
{
"id": "ulid",
"first_name": "Ahmed",
"last_name": "Hassan",
"full_name": "Ahmed Hassan",
"email": "ahmed.h@gmail.com",
"status": "approved",
"crowd_type": { "id": "ulid", "name": "Vrijwilliger", "system_type": "VOLUNTEER" },
"is_available": false,
"already_assigned": false,
"conflict": {
"section_name": "EHBO",
"shift_title": "EHBO Post",
"time_slot_name": "Zaterdag Dag",
"time": "10:00–18:00"
}
}
]
}
Query Parameters (index)
status— filter by assignment status (pending_approval,approved,rejected,cancelled,completed)shift_id— filter by shiftperson_id— filter by personsection_id— filter by festival section
Assign Body
POST /organisations/{org}/events/{event}/sections/{section}/shifts/{shift}/assign
{ "person_id": "01JXYZ..." }
Organizer manually assigns a person. Assignment is pre-approved (status = approved).
Validates: shift must be open, capacity not full (slots_total), no time slot conflict.
Claim Body
POST /organisations/{org}/events/{event}/sections/{section}/shifts/{shift}/claim
{ "person_id": "01JXYZ..." }
Volunteer claims a shift. Status depends on festival_section.crew_auto_accepts:
true→ status =approved,auto_approved = truefalse→ status =pending_approval
Validates: shift must be open, person must be approved, claiming capacity not full (slots_open_for_claiming), no time slot conflict.
Reject Body
{ "reason": "Onvoldoende ervaring voor deze rol." }
Bulk Approve Body
{ "assignment_ids": ["ulid1", "ulid2", ...] }
Response includes per-assignment result: approved or skipped (with reason).
ShiftAssignmentResource
{
"id": "01JXYZ...",
"shift_id": "01JXYZ...",
"person_id": "01JXYZ...",
"time_slot_id": "01JXYZ...",
"status": "pending_approval",
"auto_approved": false,
"assigned_by": null,
"assigned_at": "2026-04-10T12:00:00+00:00",
"approved_by": null,
"approved_at": null,
"rejection_reason": null,
"hours_expected": null,
"hours_completed": null,
"checked_in_at": null,
"checked_out_at": null,
"is_cancellable": true,
"is_approvable": true,
"created_at": "2026-04-10T12:00:00+00:00",
"person": { "..." },
"shift": { "..." }
}
Status Transitions
pending_approval→approved,rejected,cancelledapproved→cancelled,completedrejected→ (terminal)cancelled→ (terminal)completed→ (terminal)
Authorization
| Action | Who |
|---|---|
| assign | org_admin, event_manager |
| claim | authenticated org member |
| approve / reject / bulk-approve | org_admin, event_manager |
| cancel | org_admin, event_manager, or the volunteer's own user |
Volunteer Availabilities
GET /organisations/{org}/events/{event}/persons/{person}/availabilities— list availabilitiesPOST /organisations/{org}/events/{event}/persons/{person}/availabilities/sync— sync (replace all)
Sync Body
{
"availabilities": [
{ "time_slot_id": "01JXYZ...", "preference_level": 5 },
{ "time_slot_id": "01JABC...", "preference_level": 2 }
]
}
Replaces all existing availabilities for the person. preference_level is optional (default: 3, range: 1–5).
Validates:
- All
time_slot_ids must belong to the event (or parent festival) - Time slot
person_typemust match the person's crowd typesystem_type
Persons
GET /organisations/{org}/events/{event}/personsPOST /organisations/{org}/events/{event}/personsGET /organisations/{org}/events/{event}/persons/{person}PUT /organisations/{org}/events/{event}/persons/{person}POST /organisations/{org}/events/{event}/persons/{person}/approveDELETE /organisations/{org}/events/{event}/persons/{person}POST /organisations/{org}/events/{event}/persons/from-member— create person from org memberGET /organisations/{org}/members/available-for-event/{event}— list members not yet added to event
Create Person from Member
POST /organisations/{org}/events/{event}/persons/from-member
Creates a Person record from an existing organisation member. The person is created with status: approved and user_id pre-linked.
{
"user_id": "01JXYZ...",
"crowd_type_id": "01JXYZ..."
}
Person data (first_name, last_name, email) is copied from the user account. Returns PersonResource (201).
Validation:
- User must belong to the organisation
- User must not already be a person at this event (422)
- crowd_type_id must belong to the organisation
Available Members for Event
GET /organisations/{org}/members/available-for-event/{event}
Returns organisation members who do NOT yet have a Person record at the specified event. Used to populate the "Lid toevoegen" dialog.
{
"data": [
{
"id": "01JXYZ...",
"first_name": "Jan",
"last_name": "de Vries",
"full_name": "Jan de Vries",
"email": "jan@example.nl"
}
]
}
Auth: org member or higher on the organisation.
Identity Matches
Endpoints
GET /organisations/{org}/identity-matches— list pending matches for the organisation (paginated, 25 per page)GET /organisations/{org}/persons/{person}/identity-match— show pending match for a specific personPOST /organisations/{org}/identity-matches/{match}/confirm— confirm a match (linksperson.user_id, dismisses other pending matches, syncs tags)POST /organisations/{org}/identity-matches/{match}/dismiss— dismiss a match (hidden, person stays unlinked, not re-suggested)POST /organisations/{org}/identity-matches/{match}/revert— revert a confirmed match (unlinksperson.user_id, status →reverted)POST /organisations/{org}/identity-matches/bulk-confirm— bulk confirm multiple matchesPOST /organisations/{org}/events/{event}/persons/{person}/manual-link— manually link a person to a user account (body:{ "user_id": "ulid" })POST /organisations/{org}/events/{event}/persons/{person}/unlink— unlink a person from their user account
Match Types (IdentityMatchMethod)
| Value | Description | Confidence |
|---|---|---|
email |
Exact email match within org | high |
name_fuzzy |
Levenshtein fuzzy name match | medium (or high if DOB also matches) |
manual |
Organiser-initiated manual link | high |
Match Confidence (IdentityMatchConfidence)
| Value | Description |
|---|---|
high |
High certainty — exact email, or fuzzy name + DOB match |
medium |
Moderate certainty — fuzzy name match without DOB |
Match Status (IdentityMatchStatus)
| Value | Description |
|---|---|
pending |
Awaiting organiser review |
confirmed |
Organiser confirmed — person.user_id is linked |
dismissed |
Organiser dismissed — not re-suggested |
reverted |
Previously confirmed, then unlinked |
Detection
Matches are detected automatically via PersonObserver:
- On Person create: if person has no
user_idand has an email or name,PersonIdentityService::detectMatches()runs - On Person email update: if person's email changed and person is unlinked, detection re-runs
- On user creation:
PersonIdentityService::detectMatchesForUser()finds all unlinked persons with matching email
Detection strategies (in priority order):
- Exact email within same organisation →
email/high - Fuzzy name (Levenshtein distance ≤2 for short names, ≤3 for longer) →
name_fuzzy/medium - Fuzzy name + DOB match → upgrades to
highconfidence
No silent auto-linking. Every identity link requires explicit confirmation.
Bulk Confirm
POST /organisations/{org}/identity-matches/bulk-confirm
Body: { "match_ids": ["ulid1", "ulid2", ...] } (max 100)
Response: { "confirmed": 2, "errors": [{ "match_id": "ulid3", "error": "User already has a person record with this crowd type in this event." }] }
PersonResource enrichment
GET /organisations/{org}/events/{event}/persons now includes:
{
"has_user_account": true,
"user_account": { "id": "ulid", "email": "jan@example.nl", "full_name": "Jan de Vries" },
"pending_identity_match": {
"match_id": "ulid",
"matched_user": { "id": "ulid", "first_name": "Jan", "last_name": "de Vries", "full_name": "Jan de Vries", "email": "jan@example.nl", "date_of_birth": "1990-01-01" },
"matched_on": "email",
"matched_on_label": "E-mail match",
"confidence": "high",
"confidence_label": "Hoge zekerheid",
"match_details": { "matched_fields": ["email"], "..." : "..." }
}
}
Crowd Lists
GET /organisations/{org}/events/{event}/crowd-lists— list all crowd lists for event (includespersons_count)POST /organisations/{org}/events/{event}/crowd-lists— create crowd listPUT /organisations/{org}/events/{event}/crowd-lists/{list}— update crowd listDELETE /organisations/{org}/events/{event}/crowd-lists/{list}— delete crowd listGET /organisations/{org}/events/{event}/crowd-lists/{list}/persons— list persons on a crowd list (paginated, 50/page, includescrowd_list_pivot)POST /organisations/{org}/events/{event}/crowd-lists/{list}/persons— add person to listDELETE /organisations/{org}/events/{event}/crowd-lists/{list}/persons/{person}— remove person from list
Create/Update Body
{
"crowd_type_id": "01JXYZ...",
"name": "VIP Gastenlijst",
"type": "internal|external",
"recipient_company_id": "01JXYZ... (nullable, for external lists)",
"auto_approve": false,
"max_persons": 50
}
Add Person Body
{
"person_id": "01JXYZ..."
}
Business rules:
max_persons: when set, adding a person beyond the limit returns 422auto_approve: when true, adding a person with statuspendingautomatically sets their status toapproved- Duplicate person on same list returns 422
CrowdListResource
{
"id": "01JXYZ...",
"event_id": "01JXYZ...",
"crowd_type_id": "01JXYZ...",
"name": "VIP Gastenlijst",
"type": "internal",
"recipient_company_id": null,
"auto_approve": false,
"max_persons": 50,
"is_full": false,
"created_at": "2026-04-10T12:00:00+00:00",
"persons_count": 12
}
Locations
GET /organisations/{org}/events/{event}/locationsPOST /organisations/{org}/events/{event}/locationsPUT /organisations/{org}/events/{event}/locations/{location}DELETE /organisations/{org}/events/{event}/locations/{location}
Person Tags (Organisation Settings)
GET /organisations/{org}/person-tags— list active tags (ordered)POST /organisations/{org}/person-tags— create tagPUT /organisations/{org}/person-tags/{tag}— update tagDELETE /organisations/{org}/person-tags/{tag}— deactivate tag (soft: setsis_active = false)GET /organisations/{org}/person-tag-categories— distinct categories for autocomplete
User Tag Assignments
GET /organisations/{org}/users/{user}/tags— list all tags for user in organisationPOST /organisations/{org}/users/{user}/tags— assign a tagDELETE /organisations/{org}/users/{user}/tags/{tagAssignment}— remove assignmentPUT /organisations/{org}/users/{user}/tags/sync— sync tags by source
Sync endpoint: Replaces tags of the specified
sourceonly. Body:{ "tag_ids": ["ulid1", "ulid2"], "source": "self_reported" }Removesself_reportedtags not in the list, adds new ones, leavesorganiser_assigneduntouched (and vice versa).
Person list tag filtering
GET /organisations/{org}/events/{event}/persons?tag={person_tag_id}— filter persons by single tagGET /organisations/{org}/events/{event}/persons?tags=ulid1,ulid2— filter persons by multiple tags (AND logic: must have all)
Portal
POST /portal/token-auth— public. Validates a portal token against artists/production_requests tables. Returns{ context, data, event }on success. Returns 501 if token tables don't exist yet, 401 if token is invalid.GET /portal/me— auth:sanctum. Returns the authenticated user's person record for a given event. Query param:event_id(required, ULID). Resolves sub-events to parent festival. ReturnsPersonResourcewith crowdType, shiftAssignments, and volunteerAvailabilities eager-loaded. Returns 404 if no registration found.GET /portal/my-shifts— auth:sanctum. Returns all active shift assignments across all events for the authenticated user. Finds all Person records linked viauser_id(approved/pending status), then returns their active assignments (approved/pending_approval). Response grouped by event → date.GET /portal/events/{event}/available-shifts— auth:sanctum. Returns shifts available to claim, grouped by date → time slot. Requires approved person status.GET /portal/events/{event}/my-shifts— auth:sanctum. Returns the user's shift assignments for a specific event, categorized as upcoming/past/cancelled.POST /portal/events/{event}/shifts/{shift}/claim— auth:sanctum. Claim a shift. Returns assignment with status (pending_approval or approved based on section auto-accept).POST /portal/events/{event}/assignments/{shiftAssignment}/cancel— auth:sanctum. Cancel own assignment. Must be future and cancellable status.
Portal My-Shifts Response
{
"data": [
{
"event": { "id": "ulid", "name": "Festival X", "start_date": "2026-07-01", "end_date": "2026-07-03" },
"assignments": [
{
"date": "2026-07-01",
"date_label": "Woensdag 1 juli",
"shifts": [
{
"id": "ulid",
"status": "approved",
"shift": {
"id": "ulid",
"title": "Tapper",
"section_name": "Bar",
"section_icon": "tabler-beer",
"time_slot_name": "Avond",
"date": "2026-07-01",
"start_time": "18:00",
"end_time": "23:00",
"report_time": "17:30",
"location": { "name": "Hoofdpodium", "address": "Festivalplein 1" }
}
}
]
}
]
}
]
}
Form Builder
Universal form builder per /dev-docs/ARCH-FORM-BUILDER.md. Replaces the
legacy registration-form-fields / person-field-values /
registration-field-templates / person-section-preferences endpoints
purged in S2a. All authenticated routes are namespaced under
/organisations/{organisation}/forms/* with auth:sanctum + FormBuilder
policies. Public routes live at /public/forms/*.
Authenticated — Form Schemas
GET /organisations/{organisation}/forms/schemas— paginated list (default 25). ReturnsFormSchemaSummaryResourceitems.POST /organisations/{organisation}/forms/schemas— body:{ name, purpose, submission_mode?, locale?, snapshot_mode?, freeze_on_submit?, retention_days?, consent_version?, ... }. ReturnsFormSchemaResource.GET /organisations/{organisation}/forms/schemas/{form_schema}— full resource with filteredfields.PUT /organisations/{organisation}/forms/schemas/{form_schema}— updates fields; structural changes bumpversion.DELETE /organisations/{organisation}/forms/schemas/{form_schema}— soft delete. If submissions exist, requires?confirmed_name=<schema.name>per §22.8 (422 without).POST /organisations/{organisation}/forms/schemas/{form_schema}/duplicatePOST /organisations/{organisation}/forms/schemas/{form_schema}/publishPOST /organisations/{organisation}/forms/schemas/{form_schema}/unpublishPOST /organisations/{organisation}/forms/schemas/{form_schema}/rotate-public-token— body:{ grace_days?: int (default 7) }. Moves currentpublic_tokentopublic_token_previous; old token returns 410 after grace window.POST /organisations/{organisation}/forms/schemas/{form_schema}/edit-lock— 409 if another user holds a valid lock.DELETE /organisations/{organisation}/forms/schemas/{form_schema}/edit-lock
Authenticated — Form Fields (within a schema)
GET /organisations/{organisation}/forms/schemas/{form_schema}/fieldsPOST /organisations/{organisation}/forms/schemas/{form_schema}/fields— body validatesfield_typeagainstFormFieldTypeenum + any registeredcustom_field_types.PUT /organisations/{organisation}/forms/schemas/{form_schema}/fields/{form_field}— settingforce_binding_change=truebypasses the §6.5 guard.DELETE /organisations/{organisation}/forms/schemas/{form_schema}/fields/{form_field}— requires?confirmed_name=<field.label>when the field has values.POST /organisations/{organisation}/forms/schemas/{form_schema}/fields/reorder— body:{ field_ids: [<ulid>, ...] }.POST /organisations/{organisation}/forms/schemas/{form_schema}/fields/insert-from-library— body:{ library_field_id, overrides? }.
Authenticated — Form Submissions
GET /organisations/{organisation}/forms/schemas/{form_schema}/submissionsPOST /organisations/{organisation}/forms/schemas/{form_schema}/submissions— creates a draft. Body:{ subject_type?, subject_id?, is_test?, opened_at?, idempotency_key? }.GET /organisations/{organisation}/forms/submissions/{form_submission}PUT /organisations/{organisation}/forms/submissions/{form_submission}/field-values— bulk upsert draft values. Body:{ values: { <slug>: <value_or_array> } }. 403 whenFieldAccessService::canWriterejects a slug.POST /organisations/{organisation}/forms/submissions/{form_submission}/submit— optionalvaluesaccepted in-place. On submit: storesschema_version_at_submit; whenschema.snapshot_mode != 'never'storesschema_snapshot; computes SIGNATURE hashes per §9; firesFormSubmissionSubmitted— triggering the §31.10 TAG_PICKER sync listener.POST /organisations/{organisation}/forms/submissions/{form_submission}/review— body:{ status: FormSubmissionReviewStatus, review_notes? }.POST /organisations/{organisation}/forms/submissions/{form_submission}/delegate— body:{ delegated_to_user_id (scoped to org), message? }.DELETE /organisations/{organisation}/forms/submissions/{form_submission}/delegations/{delegation}DELETE /organisations/{organisation}/forms/submissions/{form_submission}
Authenticated — Templates, Field Library, Webhooks
GET/POST/PUT/DELETE /organisations/{organisation}/forms/templates[/{form_template}]— system templates are read-only for non-super-admins.GET/POST/PUT/DELETE /organisations/{organisation}/forms/field-library[/{field_library}]GET/POST/PUT/DELETE /organisations/{organisation}/forms/schemas/{form_schema}/webhooks[/{webhook}]— responses returnurl_host+has_secret; the raw URL and secret never leak out.
Authenticated — Filter Registry
GET /organisations/{organisation}/forms/filter-registry?event_id=<ulid?>— combines entity_column definitions (config/form_filter_registry.php) with TAG_PICKER-backed tags and everyis_filterable=trueform_field. Response items carry asourcediscriminator ofentity_column/tags/form_field. Cached per(organisation_id, event_id?); used by the Personen module throughFilterQueryBuilder(ARCH §7.4–§7.5). The builder rejects filters referencing invisible fields with 403 (tied toFieldAccessService).
Public (no auth, rate-limited)
GET /public/forms/{public_token}— returnsPublicFormSchemaResource(portal-visible, non-admin-only fields only; no PII hints; no submissions_count; no role_restrictions bleed).public_tokenis matched againstform_schemas.public_tokenfirst andpublic_token_previoussecond; if the rotated token has exceeded the 7-day grace window the response is 410 Gone.POST /public/forms/{public_token}/submissions— body:{ values, public_submitter_name?, public_submitter_email?, captcha_token?, idempotency_key? }. Captcha (Cloudflare Turnstile) is enforced for purposes listed underconfig('form_builder.captcha.required_for_purposes'). Rate-limited per-IP per-token per-hour (form_builder.limits.max_submissions_per_public_schema_per_ip_per_hour). Private-IP webhook targets are rejected SSRF-style inDeliverFormWebhookJob.
Response shapes
FormSchemaResource includes fields_count, submissions_count,
has_submissions, is_locked, public_form_url (when public_token
is set), and a filtered fields collection.
FormSubmissionResource.values is keyed by field slug and already
filtered through FieldAccessService::filterVisibleFields so admin-only
fields never leak to non-admins.
FormFieldResource carries available_tags (category-filtered) for
TAG_PICKER fields and resolves label / help_text / options
through FormLocaleResolver + the translations JSON column.
Form Builder (Public)
S2c contract for the unauthenticated portal. Six endpoints at
/api/v1/public/forms/*, every one rate-limited via the
throttle:30,1 middleware and served behind
PublicFormTokenResolver (7-day grace window on
public_token_previous; BACKLOG FORM-04 makes this configurable).
Error envelope
All public form endpoints return errors with this envelope:
{
"message": "Human-readable message",
"code": "MACHINE_READABLE_CODE",
"errors": { "field_name": ["validation message"] }
}
errors is only present on 422 validation failures (Laravel
FormRequest shape).
Codes:
| Code | HTTP | Meaning |
|---|---|---|
TOKEN_EXPIRED |
410 | public token past grace window |
TOKEN_REVOKED |
410 | token rotated without grace |
SCHEMA_UNPUBLISHED |
410 | schema exists but not published |
SCHEMA_NOT_FOUND |
404 | public token does not resolve |
SUBMISSION_ALREADY_SUBMITTED |
409 | submit on finalised submission |
RATE_LIMITED |
429 | includes Retry-After header |
VALIDATION_FAILED |
422 | per-field validation — see errors map below |
Authoritative source: api/app/Exceptions/FormBuilder/PublicFormApiException.php
and its concrete subclasses.
VALIDATION_FAILED returns the errors field keyed by values.{slug}
with an array of Dutch messages. Field-shape triggers include:
- SECTION_PRIORITY — values must be
{ section_id, priority }[]with unique section_ids and priorities in1..5, max 5 entries, and section_ids scoped to the schema's owner event tree. Specific messages land undererrors."values.{slug}":"Dezelfde sectie mag slechts één keer worden opgegeven.""Elke prioriteit mag slechts één keer worden toegekend.""priority moet tussen 1 en 5 liggen (positie {n}).""Je kunt maximaal 5 voorkeuren opgeven.""Eén of meer secties horen niet bij dit evenement.""Ongeldig formaat voor sectievoorkeuren."/"Ongeldig voorkeur-element op positie {n}."/"section_id ontbreekt op positie {n}."/"priority ontbreekt of is ongeldig op positie {n}."
Authoritative source for shape rules:
api/app/Services/FormBuilder/FormValueService::validateSectionPriorityShape.
GET /public/forms/{public_token}
Returns PublicFormSchemaResource. Shape:
{
"success": true,
"data": {
"id": "01HZ...", "name": "...", "slug": "...",
"purpose": "event_registration",
"locale": "nl",
"version": 3,
"opened_at": "2026-04-17T20:29:16+00:00",
"consent_version": null,
"submission_deadline": null,
"section_level_submit": false,
"sections": [],
"fields": [
{
"id": "01HZ...",
"slug": "shirtmaat",
"field_type": "SELECT",
"label": "Shirtmaat",
"help_text": null,
"options": ["XS","S","M","L","XL","XXL"],
"available_tags": null,
"validation_rules": null,
"is_required": true,
"display_width": "half",
"conditional_logic": null,
"sort_order": 2,
"form_schema_section_id": null
},
{
"slug": "vaardigheden",
"field_type": "TAG_PICKER",
"available_tags": [
{"id": "01HZ...", "name": "EHBO", "category": "Veiligheid"},
{"id": "01HZ...", "name": "Tapper", "category": "Horeca"}
]
}
]
}
}
Notes:
available_tagsis populated onTAG_PICKERfields only. Filter viaform_fields.validation_rules.tag_categorieswhen set, else returns every activeperson_tagfor the org.conditional_logicreferences peers byfield_slug.- Admin-only / non-portal-visible fields are filtered out entirely.
GET /public/forms/{public_token}/time-slots
AVAILABILITY_PICKER dependency data. Response:
{
"data": [
{
"id": "01HZ...",
"name": "Zaterdag ochtend",
"date": "2026-07-11",
"start_time": "08:00:00",
"end_time": "13:00:00",
"duration_hours": 5.0,
"event_id": "01HZ...",
"event_name": "Echt Feesten 2026 — Dag 2"
}
]
}
- Volunteer-only:
person_type = 'VOLUNTEER'. - Festival-aware: parent + children surfaced; deduplication is by id.
GET /public/forms/{public_token}/sections
SECTION_PRIORITY dependency data. Response:
{
"data": [
{
"id": "01HZ...",
"name": "Bar",
"category": "Horeca",
"icon": "tabler-beer",
"registration_description": "Tappen en serveren"
}
]
}
show_in_registration = true AND type = 'standard'.- Festival-aware: children surfaced, deduplicated by name.
POST /public/forms/{public_token}/submissions — create draft
Body:
{
"idempotency_key": "01HZ...",
"opened_at": "2026-04-17T20:20:00+00:00",
"submitted_in_locale": "nl",
"public_submitter_name": "Anonieme tester",
"public_submitter_email": "test@example.nl"
}
idempotency_keyis REQUIRED (6–30 chars). Duplicate replay returns the existing draft withHTTP 200instead of201.- Response is a
PublicFormSubmissionResourcewithstatus: "draft"andschema_version_at_openstamped.
PUT /public/forms/{public_token}/submissions/{submission_id} — auto-save
Body:
{
"values": {"shirtmaat": "L", "vaardigheden": ["01HZ...", "01HZ..."]},
"first_interacted_at": "2026-04-17T20:21:05+00:00"
}
- Partial updates allowed. Only slugs present in the body are written; unrelated saved values stay intact.
- Relaxed rule set at the request layer (nullable + type check). The
service layer still enforces
validation_rules.min/max/regex/unique. - Every PUT increments
auto_save_countand firesFormSubmissionDraftUpdatedon the domain event bus. - 409 if the submission is not status=draft. 404 if
submission_idbelongs to a different schema.
POST /public/forms/{public_token}/submissions/{submission_id}/submit — finalize
Body:
{
"values": {"opmerkingen": "final remark"},
"captcha_token": "..."
}
- Merges the body with already-saved values and runs the strict rule
set against the merged map. Required fields must be present
somewhere (saved or in the body) or the server returns
VALIDATION_FAILED. - Fires
FormSubmissionSubmitted— triggers the §31.10 TAG_PICKER sync and §31.1 identity-match listeners. - Rate-limited per
(public_token, ip)per hour. Exceed returnsRATE_LIMITEDwithRetry-Afterheader.
Response:
{
"success": true,
"data": {
"id": "01HZ...",
"form_schema_id": "01HZ...",
"status": "submitted",
"auto_save_count": 4,
"submitted_in_locale": "nl",
"schema_version_at_submit": 3,
"schema_drift": false,
"values": { "shirtmaat": {"value": "L", "value_anonymised": false} },
"identity_match": {
"status": "pending",
"message": "We controleren of je al bekend bent bij de organisator. Je gegevens worden gekoppeld zodra zij dit bevestigen."
},
"opened_at": "...",
"first_interacted_at": "...",
"submitted_at": "...",
"submission_duration_seconds": 120,
"created_at": "...",
"updated_at": "..."
}
}
schema_driftis true whenschema_version_at_open != schema_version_at_submit(organiser edited the schema during the draft).identity_match.statusis one ofnull | pending | matched | noneper ARCH §31.1.- No PII echo.
public_submitter_name,public_submitter_email,public_submitter_ip, andsubmitted_by_user_idare never included in the response.
Person List Filtering (extended)
Additional filter parameters on GET /organisations/{org}/events/{event}/persons:
?section_preference={section_id}— filter by section preference (has this section as any priority)?has_preference=true— only persons who submitted section preferences
Form-field-value filtering (?field[slug]=value) was served by the legacy
endpoints that were purged in S2a. It returns in S2b on top of
form_values + form_value_options via the FilterQueryBuilder described
in /dev-docs/ARCH-FORM-BUILDER.md §7.
(Extend this contract per module as endpoints are implemented.)
Platform Admin
All admin endpoints require auth:sanctum + role:super_admin. They bypass OrganisationScope and query across all organisations.
Base path: /api/v1/admin/
Admin Organisations
GET /admin/organisations— list all organisations (paginated)GET /admin/organisations/{organisation}— show with counts and total personsPOST /admin/organisations— not supported (405), use regular endpointPUT /admin/organisations/{organisation}— update name, slug, billing_status, settingsDELETE /admin/organisations/{organisation}— soft delete
Query Parameters (index)
search— filter by name or slug (partial match)billing_status— filter by billing status (trial,active,suspended,cancelled)sort— sort field:name(default),created_atdirection— sort direction:asc(default),desc
AdminOrganisationResource
{
"id": "01JXYZ...",
"name": "Festival Corp",
"slug": "festival-corp",
"billing_status": "active",
"billing_status_label": "Active",
"settings": {},
"events_count": 5,
"users_count": 12,
"total_persons": 342,
"created_at": "2026-01-15T10:00:00+00:00",
"updated_at": "2026-04-10T12:00:00+00:00",
"deleted_at": null
}
Update Body
{
"name": "Festival Corp",
"slug": "festival-corp",
"billing_status": "suspended",
"settings": {}
}
Admin Users
GET /admin/users— list all users with organisation memberships (paginated)GET /admin/users/{user}— show with organisations and rolesPUT /admin/users/{user}— update name, email, timezone, locale, platform rolesDELETE /admin/users/{user}— soft delete + revoke all tokens
Query Parameters (index)
search— filter by first_name, last_name, or email (partial match)organisation_id— filter by organisation membershiprole— filter by Spatie role name
AdminUserResource
{
"id": "01JXYZ...",
"first_name": "Jan",
"last_name": "de Vries",
"full_name": "Jan de Vries",
"email": "jan@example.nl",
"avatar": null,
"timezone": "Europe/Amsterdam",
"locale": "nl",
"email_verified_at": "2026-01-15T10:00:00+00:00",
"created_at": "2026-01-15T10:00:00+00:00",
"roles": ["super_admin"],
"is_super_admin": true,
"organisations": [
{ "id": "01JXYZ...", "name": "Festival Corp", "slug": "festival-corp", "role": "org_admin" }
]
}
Update Body
{
"first_name": "Jan",
"last_name": "de Vries",
"email": "jan@example.nl",
"timezone": "Europe/Amsterdam",
"locale": "nl",
"roles": ["super_admin"]
}
roles accepts platform-level roles only: super_admin, support_agent. Organisation/event roles are managed via the regular endpoints.
Admin Stats
GET /admin/stats— platform-wide aggregate counts
Response
{
"data": {
"organisations": {
"total": 15,
"by_billing_status": { "trial": 3, "active": 10, "suspended": 1, "cancelled": 1 }
},
"events": {
"total": 42,
"by_status": { "draft": 10, "published": 8, "registration_open": 12, "showday": 5, "closed": 7 }
},
"users": {
"total": 156,
"verified": 142
},
"persons": {
"total": 2340
}
}
}
Admin Activity Log
GET /admin/activity-log— paginated activity log (25 per page)
Query Parameters
causer_id— filter by user who caused the actionsubject_type— filter by subject model typelog_name— filter by log name (e.g.admin,default)from— filter from date (ISO 8601)to— filter to date (ISO 8601)
Response
{
"data": [
{
"id": 1,
"log_name": "admin",
"description": "Updated organisation Festival Corp",
"event": "admin.organisation.updated",
"causer": { "id": "01JXYZ...", "name": "Super Admin", "email": "admin@crewli.app" },
"subject_type": "App\\Models\\Organisation",
"subject_id": "01JXYZ...",
"properties": { "billing_status": "suspended" },
"created_at": "2026-04-14T10:00:00+00:00"
}
],
"meta": { "current_page": 1, "last_page": 1, "per_page": 25, "total": 1 }
}
Admin Impersonation
Header-based impersonation with MFA verification. See AUTH_ARCHITECTURE.md section 10 for full details.
Endpoints:
POST /admin/impersonate/{user}— start impersonation (requiresrole:super_admin+ MFA)POST /admin/stop-impersonation— stop impersonation (requiresauth:sanctumonly — admin calls withoutX-Impersonate-Userheader)GET /admin/impersonate/status— check active session (requiresrole:super_admin)POST /admin/impersonate/send-mfa-code— send email verification code to admin (requiresrole:super_admin)
Start Request
{
"reason": "Investigating user issue with shift assignments",
"mfa_code": "123456",
"mfa_method": "totp"
}
| Field | Type | Rules |
|---|---|---|
reason |
string | required, min:5, max:500 |
mfa_code |
string | required |
mfa_method |
string | required, in: totp, email, backup_code |
Start Response
{
"data": {
"session": {
"id": "01JXYZ...",
"admin_id": "01JXYZ...",
"target_user_id": "01JXYZ...",
"reason": "Investigating user issue",
"mfa_method": "totp",
"started_at": "2026-04-16T12:00:00+00:00",
"expires_at": "2026-04-16T13:00:00+00:00",
"ended_at": null,
"end_reason": null,
"actions_count": 0
},
"user": { "...AdminUserResource..." }
}
}
Stop Response
{
"data": {
"user": { "...AdminUserResource (original admin)..." }
}
}
Status Response
{
"data": {
"active": true,
"session": { "...ImpersonationSessionResource..." }
}
}
Impersonation Header
During an active session, the frontend sends X-Impersonate-User: {target_user_id} on every request. The HandleImpersonation middleware validates the header against the cached session and swaps auth context.
Business Rules
- Admin must have MFA enabled (403 if not)
- Cannot impersonate another super_admin (403)
- Cannot nest impersonation sessions (403)
- Cannot impersonate a user already being impersonated (403)
- MFA code verified against admin's own MFA (TOTP, email, or backup code)
- Sessions expire after 60 minutes (sliding TTL, extended on each request)
- IP pinning: session terminated if admin's IP changes
- Sensitive routes blocked during impersonation (auth/, me/, admin/impersonate/*)
- All activity during session tagged with
impersonated_byin properties - Immutable audit trail in
impersonation_sessionstable
Email Settings (org admin)
GET /organisations/{org}/email-settings— get current email branding settings (returns defaults if none configured)PUT /organisations/{org}/email-settings— create or update email branding settings
PUT body
{
"logo_url": "https://example.com/logo.png",
"primary_color": "#FF5500",
"secondary_color": "#CC4400",
"footer_text": "© 2026 Stichting Feestfabriek",
"reply_to_email": "info@festival.nl",
"reply_to_name": "Festival Team"
}
All fields are optional/nullable. Colors must match #[0-9A-Fa-f]{6}.
Email Templates (org admin)
GET /organisations/{org}/email-templates— list all template types with current content (custom or default)GET /organisations/{org}/email-templates/{type}— get single template with both custom content and defaultsPUT /organisations/{org}/email-templates/{type}— create or update custom template for this typeDELETE /organisations/{org}/email-templates/{type}— reset to system default (deletes custom override)POST /organisations/{org}/email-templates/{type}/preview— render email HTML with sample dataPOST /organisations/{org}/email-templates/{type}/send-test— send test email. Body:{ "email": "test@example.com" }
Template types
invitation, password_reset, email_verification, registration_approved, registration_rejected, shift_assignment
PUT body
{
"subject": "Aangepaste uitnodiging voor {organisation_name}",
"heading": "Welkom!",
"body_text": "Aangepaste tekst met {organisation_name} variabelen.",
"button_text": "Klik hier"
}
GET response (index)
Returns array of all template types with resolved content:
{
"data": [
{
"type": "invitation",
"label": "Uitnodiging",
"is_custom": false,
"subject": "Je bent uitgenodigd voor {organisation_name}",
"heading": "Welkom bij {organisation_name}!",
"body_text": "...",
"button_text": "Uitnodiging accepteren",
"defaults": { "subject": "...", "heading": "...", "body_text": "...", "button_text": "..." }
}
]
}
Template variables
{organisation_name}— available in all templates{event_name}— available in registration and shift templates{shift_title},{shift_date},{shift_start},{shift_end},{section_name}— shift assignment only
Email Logs (org admin, read-only)
GET /organisations/{org}/email-logs— paginated email log
Query parameters
| Param | Type | Description |
|---|---|---|
search |
string | Search by recipient_email |
status |
string | Filter: queued|sent|failed |
template_type |
string | Filter by EmailTemplateType |
event_id |
ULID | Filter by event |
person_id |
ULID | Filter by person |
from |
date | Start of date range |
to |
date | End of date range |
per_page |
int | Results per page (default 15) |
Response
{
"data": {
"data": [
{
"id": "01JXYZ...",
"recipient_email": "volunteer@test.nl",
"recipient_name": "Jan Janssen",
"template_type": "registration_approved",
"template_label": "Registratie goedgekeurd",
"subject": "Je registratie voor Festival X is goedgekeurd!",
"status": "sent",
"error_message": null,
"queued_at": "2026-04-15T12:00:00+00:00",
"sent_at": "2026-04-15T12:00:05+00:00",
"failed_at": null,
"triggered_by": { "id": "01JXYZ...", "name": "Admin User" },
"event_id": "01JXYZ...",
"person_id": "01JXYZ...",
"created_at": "2026-04-15T12:00:00+00:00"
}
],
"links": { "...pagination..." },
"meta": { "...pagination..." }
}
}