How the module is built. For how it is used — building a form, publishing it, routing what it collects — see Forms.
Forms in Aexy are a general-purpose data-collection layer that can produce tickets, CRM records, deals, hiring applications, or just FormSubmission rows. What a form produces is a list of connections. The same form definition powers both authenticated internal use and public anonymous submission.
Pieces#
- Form — definition (fields, theme, routing, automations)
- FormField — typed column on the form
- FormSubmission — one user's response
- FormConnection — one thing a form's submissions create: a module, its settings, and field mappings
- FormSubmissionResult — what one connection did with one submission
- TicketForm — a specialized Form sized for support intake (lives in
models/ticketing.py) - Visual builder — the schema + preview API that powers the drag-drop editor
Routers#
| Router | Prefix | Use |
|---|---|---|
api/forms.py | /workspaces/{ws}/forms | Authenticated CRUD |
api/public_forms.py | /public/forms | Anonymous submission |
api/ticket_forms.py | /workspaces/{ws}/ticket-forms | Ticketing-specific forms (see tickets-and-projects.md) |
api/visual_builder.py | — | Schema, validation, preview for the visual builder |
api/question_bank.py | /workspaces/{ws}/question-bank | Reusable questions |
Form model (models/forms.py)#
| Field | Note |
|---|---|
workspace_id, name, slug | Identity |
description | Help text shown at the top of the form |
template_type | bug_report / feature_request / support / contact / lead_capture / feedback / job_application / custom |
public_url_token | Unique — drives the /public/forms/{token} route |
is_active | |
auth_mode | anonymous / email_verification (OTP) |
require_email | |
theme (JSONB) | Full visual customization (see below) |
success_message, redirect_url | Post-submit UX |
connections | Relationship to form_connections (below) |
trigger_automations | Whether to fire workspace automations on submit |
conditional_rules (JSONB) | Show/hide/require logic |
submission_count | Cached |
Theme JSONB#
The theme is fully structured — frontend reads it and applies element-by-element:
{
"preset": "light|dark|minimal|modern|colorful|corporate|null",
"global": {
"primary_color": "#...",
"secondary_color": "#...",
"background_color": "#...",
"font_family": "...",
"border_radius": "...",
"spacing": "compact|normal|relaxed"
},
"elements": {
"form": { "background_color": "...", "padding": "...", "max_width": "...", "shadow": "..." },
"header": { "text": "...", "text_color": "...", "font_size": "...", "font_weight": "...", "alignment": "left|center|right", "logo_url": "...", "logo_position": "..." },
"labels": { "text_color": "...", "font_size": "...", "required_indicator_color": "..." },
"inputs": { "background_color": "...", "border_color": "...", "text_color": "...", "focus_border_color": "...", "border_radius": "..." },
"buttons": { "primary": { "...": "..." }, "secondary": { "...": "..." } },
"errors": { "text_color": "...", "background_color": "...", "border_color": "...", "icon_color": "..." },
"help_text": { "text_color": "...", "font_size": "..." }
},
"custom_css": "..."
}
A preset is a named bundle of global+elements values; setting a preset and then overriding specific fields is the common pattern.
Conditional rules JSONB#
[
{ "field_key": "...", "operator": "equals|contains|...", "value": "...", "action": "show|hide|require" }
]
Evaluated client-side as the user types. The server still treats every defined field as authoritative — conditional hide is a UX nicety, not a validation guarantee.
FormField#
| Field | Note |
|---|---|
form_id, name, field_key | Identity |
field_type | text / textarea / email / phone / number / url / select / multiselect / checkbox / radio / file / date / datetime / hidden |
placeholder, default_value, help_text | UX |
is_required | |
validation_rules (JSONB) | {validation_type, min_length, max_length, pattern, custom_message, allowed_file_types, max_file_size_mb} |
options | For select/radio |
position, is_visible, width | Layout |
crm_attribute_id | Map to a CRM attribute for record creation |
external_mappings (JSONB) | {github, jira, linear} field mappings |
Connections (form_connections, services/form_connections.py)#
Until 0.49.0 each destination was a column set on forms (ticket: eight,
CRM record: four, deal: five, external: a JSON list) with its own foreign key on
form_submissions, its own endpoints, tab and handler branch. They are one
table now:
| Field | Note |
|---|---|
form_id, workspace_id | Owner |
module | ticket / crm_record / deal / hiring_candidate; github / jira / linear are legacy (below) |
is_enabled | Off keeps the settings |
position | Run order |
config (JSONB) | The module's settings |
field_mappings (JSONB) | {form_field_key: target_field} — the same shape for every module |
Unique on (form_id, module): a form makes at most one of each.
Each module in MODULES is a ConnectionModule that declares three things:
targets— the fields answers can map onto (Nonefor CRM record and deal: the attributes of the chosen CRM object). A target withaccepts="file"only takes file fields.validate(db, workspace_id, config)— run on save. Every id in the settings must belong to the form's workspace (team, assignee, CRM object, job, stage, owner). Mappings may only name the form's own fields plus the contact block'snameandemail.run(ctx, config, mappings)— create the record from a submission.
| Module | config |
|---|---|
ticket | team_id, assignment_mode (none / oncall / round_robin / specific_user), assignee_id, default_priority, default_severity, title_template |
crm_record | object_id (required), owner_id |
deal | pipeline_id, stage_id, owner_id, link_to_record; object_id is the workspace's deal object, filled on save |
hiring_candidate | requirement_id (a job) or role, stage_id (an open stage on that job's board), owner_id, source, tags |
github, jira and linear were configurable before and never
implemented — their handlers returned None. They cannot be added any more;
forms that had them keep the rows, and each run records a skipped result
saying so.
Adding a destination is a new ConnectionModule in MODULES, with no schema
change.
FormSubmission#
| Field | Note |
|---|---|
form_id | Source |
status | PENDING / PROCESSING / COMPLETED / PARTIALLY_FAILED / FAILED |
email, name | The contact block |
data (JSONB) | Keyed by field_key |
attachments (JSONB) | Files staged by the public upload endpoint |
results | Relationship to form_submission_results (below) |
processing_errors (JSONB) | One entry per failed connection |
Results (form_submission_results)#
One row per connection that ran: module, status (created / failed /
skipped), target_id (the ticket, record or hiring application — no foreign
key, since it points at a different table per module), target_label (a ticket
number, a record name), details, error. The submission responses derive
ticket_id, crm_record_id, deal_id and application_id from them.
PARTIALLY_FAILED means some connections created their record and some failed;
FAILED means none did.
Endpoints#
# Authenticated CRUD
GET /workspaces/{ws}/forms
POST /workspaces/{ws}/forms
GET /workspaces/{ws}/forms/{form_id} include_fields=true to embed fields
PATCH /workspaces/{ws}/forms/{form_id}
DELETE /workspaces/{ws}/forms/{form_id}
POST /workspaces/{ws}/forms/{form_id}/duplicate clone with new name
# Fields
POST /workspaces/{ws}/forms/{form_id}/fields
PATCH /workspaces/{ws}/forms/{form_id}/fields/{field_id}
DELETE /workspaces/{ws}/forms/{form_id}/fields/{field_id}
POST /workspaces/{ws}/forms/{form_id}/fields/reorder field_id[] + position[]
# Submissions
GET /workspaces/{ws}/forms/{form_id}/submissions filter by status + date
GET /workspaces/{ws}/forms/{form_id}/submissions/{id}
PATCH /workspaces/{ws}/forms/{form_id}/submissions/{id} update status
DELETE /workspaces/{ws}/forms/{form_id}/submissions/{id}
# Connections
GET /workspaces/{ws}/forms/connection-modules what can be created, and each one's targets
GET /workspaces/{ws}/forms/{form_id}/connections
PUT /workspaces/{ws}/forms/{form_id}/connections/{module} {is_enabled, config, field_mappings, position}
DELETE /workspaces/{ws}/forms/{form_id}/connections/{module}
# Automations
GET/POST/DELETE /workspaces/{ws}/forms/{form_id}/automations
# Public (no auth)
GET /public/forms/{public_token} render
POST /public/forms/{public_token}/submit submission (rate-limited)
POST /public/forms/{public_token}/verify-email OTP send for email_verification mode
Public submission flow#
GET /public/forms/{public_token}returns thePublicFormResponse— fields filtered tois_visible=true, noexternal_mappingsexposed, theme included.- If
auth_mode = email_verification: the client sends the email to/verify-email, server issues an OTP, user enters it, server verifies. - Client posts to
/submitwith{email, field_values}. - Server validates, creates the
FormSubmission, then runs the form's enabled connections one at a time, inpositionorder, each inside a savepoint. A module listed as building on another (a deal on the CRM record) runs after it whatever the positions say. A connection that raises is rolled back alone and recorded asfailed; the rest still land. They used to run underasyncio.gatheron one session, which SQLAlchemy does not allow. - Linked automations, then the
form.submittedautomation event (with the created ids, includingapplication_id). - Response returns
{submission_id, ticket_number, success_message, redirect_url, requires_email_verification}.
Each downstream step that fails updates submission status to PARTIALLY_FAILED (or FAILED if everything failed), recording details on error_message.
Visual builder#
api/visual_builder.py exposes schema introspection used by the drag-drop editor:
GET /workspaces/{ws}/visual-builder/schema JSON-schema describing all field types
POST /workspaces/{ws}/visual-builder/validate validate a form JSON against schema
POST /workspaces/{ws}/visual-builder/preview render a form definition without persisting
Question bank (api/question_bank.py) is a reusable library of pre-defined form fields — useful when the same "Severity (low/medium/high/critical)" select shows up in dozens of forms.
Frontend#
/frontend/src/app/(app)/forms/ — form builder UI, field editor, submission inbox, theme customizer, conditional-logic designer.
Public render lives in /frontend/src/app/public/forms/[token]/page.tsx.
Common pitfalls#
external_mappingsexposed to anonymous users.PublicFormResponsefilters them out — never call the authenticated form endpoint from the public flow.- Conditional rules are not validation. A
hideaction removes the field from the UI but not from the schema; server-side, the field is still allowed in the submission. Don't rely on conditional rules to enforce data shape — useis_required+validation_rules. - A ticket connection without
team_id. Submissions create unassigned tickets, which sit in the inbox until someone routes them. - Reading
form.connectionsin the submit path. The handler queriesform_connectionsitself: a form loaded without them would otherwise need a lazy load, which an async session cannot do. PARTIALLY_FAILEDis silent. Submissions still respond 200 because the form was accepted. Surface the partial-failure state in the submission inbox so admins notice the CRM record didn't get created.- Public form rate limits. The submission endpoint is aggressively rate-limited per IP. For internal batch imports use the authenticated path.
- Theme JSONB is open-ended. Frontend renders unknown keys gracefully but stores them. Don't sneak app state into
theme— it'll surface in the public payload.