openapi: 3.0.3
info:
  title: Easerix Forms API
  description: |
    Form endpoints (backend/services/products/forms): projects, forms,
    submissions with a spam pipeline (honeypot, rate limit, Turnstile,
    heuristics, async AI adjudication), delivery destinations, and the public
    unauthenticated ingest endpoint that is the product itself. Responses are
    camelCase. All /v1 routes require a bearer JWT with org claims and the
    "forms" tool enabled (authkit); the root /{id} ingest routes are public.
    Form ids are 6-char nanoids, not UUIDs.
    Authored from the shipping Gin handlers — this spec records reality.
  version: 1.0.0
  contact:
    name: Perizer Labs
    url: https://easerix.com
servers:
  - url: https://f.easerix.com
    description: Production
tags:
  - name: system
    description: Health, liveness, and CORS preflight
  - name: projects
    description: Form projects (folders)
  - name: forms
    description: Form endpoints
  - name: submissions
    description: Submission inbox and spam triage
  - name: destinations
    description: Delivery destinations (email delivered in v1; others modeled)
  - name: summary
    description: Account-wide aggregates
  - name: ingest
    description: Public submission endpoints (no auth)

security:
  - bearer: []

paths:
  /health:
    get:
      operationId: health
      tags: [system]
      summary: Liveness probe
      description: Unauthenticated liveness check used by the platform and monitors.
      security: []
      responses:
        "200":
          description: Service is up
          content:
            application/json:
              schema:
                type: object

  /v1/projects:
    get:
      operationId: listProjects
      tags: [projects]
      summary: List projects
      description: >-
        Projects the caller can read: their own (active-org-scoped; legacy rows
        without org_id stay visible) plus any shared with the organization by a
        teammate. formCount counts only the forms that caller can read.
        created_at descending, limit 500.
      parameters:
        - name: scope
          in: query
          description: >-
            all (default) — everything readable · mine — only the caller's own
            · shared — only teammates' org-shared projects (excludes own).
          schema:
            type: string
            enum: [all, mine, shared]
      responses:
        "200":
          description: Projects
          content:
            application/json:
              schema:
                type: object
                required: [projects]
                properties:
                  projects:
                    type: array
                    maxItems: 500
                    items: { $ref: "#/components/schemas/Project" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
    post:
      operationId: createProject
      tags: [projects]
      summary: Create a project
      description: Name defaults to "Untitled project", color to "#2563eb".
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                color: { type: string }
      responses:
        "201":
          description: Project created
          content:
            application/json:
              schema:
                type: object
                required: [project]
                properties:
                  project: { $ref: "#/components/schemas/Project" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/projects/{pid}:
    parameters:
      - { name: pid, in: path, required: true, schema: { type: string } }
    get:
      operationId: getProject
      tags: [projects]
      summary: Project detail
      description: >-
        One project with its form count. Readable: the caller's own or one a
        teammate shared with the organization.
      responses:
        "200":
          description: Project
          content:
            application/json:
              schema:
                type: object
                required: [project]
                properties:
                  project: { $ref: "#/components/schemas/Project" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateProject
      tags: [projects]
      summary: Rename, recolor, or share a project
      description: >-
        Partial update of name/color/visibility. Owner-only — a project shared
        with the caller by a teammate is readable but returns 403 here.
        visibility must be private or org.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                color: { type: string }
                visibility: { type: string, enum: [private, org] }
      responses:
        "200":
          description: Updated project
          content:
            application/json:
              schema:
                type: object
                required: [project]
                properties:
                  project: { $ref: "#/components/schemas/Project" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      operationId: deleteProject
      tags: [projects]
      summary: Delete a project
      description: >-
        Owner-only; refused while the project still contains forms. A project
        shared with the caller returns 403.
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Project still contains forms ("move or delete this project's forms first")
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/forms:
    get:
      operationId: listForms
      tags: [forms]
      summary: List forms
      description: >-
        Forms the caller can read: their own (active-org-scoped; legacy rows
        without org_id stay visible) plus any shared with the organization by a
        teammate. created_at descending, limit 500.
      parameters:
        - { name: q, in: query, schema: { type: string }, description: Case-insensitive name search }
        - { name: projectId, in: query, schema: { type: string } }
        - name: scope
          in: query
          description: >-
            all (default) — everything readable · mine — only the caller's own
            · shared — only teammates' org-shared forms (excludes own).
          schema:
            type: string
            enum: [all, mine, shared]
      responses:
        "200":
          description: Forms
          content:
            application/json:
              schema:
                type: object
                required: [forms]
                properties:
                  forms:
                    type: array
                    maxItems: 500
                    items: { $ref: "#/components/schemas/Form" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
    post:
      operationId: createForm
      tags: [forms]
      summary: Create a form endpoint
      description: >-
        Name defaults to "Untitled form". projectId must belong to the caller
        when given; otherwise the oldest project is used and a "Default"
        project is auto-created if none exist. Created with successBehavior
        json, honeypot on, spamProvider turnstile, contentFilter medium,
        aiFilter off, active true.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                targetEmails:
                  type: array
                  items: { type: string }
                projectId: { type: string }
      responses:
        "201":
          description: Form created
          content:
            application/json:
              schema:
                type: object
                required: [form]
                properties:
                  form: { $ref: "#/components/schemas/Form" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/forms/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: getForm
      tags: [forms]
      summary: Form detail (composite)
      description: >-
        The form plus its latest 200 submissions (including spam) and a
        14-day daily series of non-spam submissions. Readable: the caller's own
        form or one a teammate shared with the organization.
      responses:
        "200":
          description: Form, recent submissions, and series
          content:
            application/json:
              schema:
                type: object
                required: [form, submissions, series]
                properties:
                  form: { $ref: "#/components/schemas/Form" }
                  submissions:
                    type: array
                    maxItems: 200
                    items: { $ref: "#/components/schemas/Submission" }
                  series:
                    type: array
                    items: { $ref: "#/components/schemas/DaySubmissions" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateForm
      tags: [forms]
      summary: Update form settings
      description: >-
        Partial update. Owner-only — a form shared with the caller by a
        teammate is readable but returns 403 here. contentFilter must be
        off/low/medium/high; visibility must be private or org; projectId must
        belong to the caller; email/origin lists are trimmed. Other enum-like
        fields are not validated server-side. The autoresponder object mirrors
        the response nesting.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                projectId: { type: string }
                targetEmails:
                  type: array
                  items: { type: string }
                allowedOrigins:
                  type: array
                  items: { type: string }
                successBehavior: { type: string, description: json | redirect (not validated) }
                redirectUrl: { type: string }
                honeypot: { type: boolean }
                spamProvider: { type: string, description: none | turnstile (not validated) }
                contentFilter: { type: string, enum: [off, low, medium, high] }
                aiFilter: { type: boolean }
                autoresponder:
                  type: object
                  properties:
                    enabled: { type: boolean }
                    subject: { type: string }
                    body: { type: string }
                active: { type: boolean }
                visibility: { type: string, enum: [private, org] }
      responses:
        "200":
          description: Updated form
          content:
            application/json:
              schema:
                type: object
                required: [form]
                properties:
                  form: { $ref: "#/components/schemas/Form" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      operationId: deleteForm
      tags: [forms]
      summary: Delete a form
      description: >-
        Owner-only; deletes the form and its submissions cascade. A form shared
        with the caller returns 403.
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/forms/{id}/submissions:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: listSubmissions
      tags: [submissions]
      summary: List submissions
      description: >-
        Non-spam submissions by default; pass includeSpam=true for everything.
        created_at descending, limit 500. Submissions are children of the form,
        so a teammate who can read a shared form can read its inbox.
      parameters:
        - name: includeSpam
          in: query
          schema: { type: string, enum: ["true"] }
          description: Spam included only when exactly "true"
      responses:
        "200":
          description: Submissions
          content:
            application/json:
              schema:
                type: object
                required: [submissions]
                properties:
                  submissions:
                    type: array
                    maxItems: 500
                    items: { $ref: "#/components/schemas/Submission" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/forms/{id}/submissions/{sid}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: sid, in: path, required: true, schema: { type: string } }
    patch:
      operationId: markSpam
      tags: [submissions]
      summary: Mark spam / not spam
      description: >-
        Flips the spam flag and adjusts the form's spamCount. Returns a
        message, not the submission. Owner-only — triaging a shared form's
        inbox returns 403.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [isSpam]
              properties:
                isSpam: { type: boolean }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      operationId: deleteSubmission
      tags: [submissions]
      summary: Delete a submission
      description: >-
        Deletes the submission and decrements the form's counters. Owner-only —
        deleting from a shared form's inbox returns 403.
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/forms/{id}/destinations:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: listDestinations
      tags: [destinations]
      summary: List destinations
      description: >-
        Delivery destinations, created_at ascending. Readable with the form, so
        a teammate a form is shared with can see where it delivers.
      responses:
        "200":
          description: Destinations
          content:
            application/json:
              schema:
                type: object
                required: [destinations]
                properties:
                  destinations:
                    type: array
                    items: { $ref: "#/components/schemas/Destination" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      operationId: createDestination
      tags: [destinations]
      summary: Add a destination
      description: >-
        type must be one of email/webhook/slack/sheets/zapier/crm — only email
        is delivered in v1. enabled defaults true. Owner-only — adding one to a
        shared form returns 403.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type]
              properties:
                type:
                  type: string
                  enum: [email, webhook, slack, sheets, zapier, crm]
                config:
                  type: object
                  additionalProperties: true
                enabled: { type: boolean }
      responses:
        "201":
          description: Destination created
          content:
            application/json:
              schema:
                type: object
                required: [destination]
                properties:
                  destination: { $ref: "#/components/schemas/Destination" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/forms/{id}/destinations/{did}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: did, in: path, required: true, schema: { type: string } }
    patch:
      operationId: updateDestination
      tags: [destinations]
      summary: Update a destination
      description: >-
        Partial update; config is replaced wholesale, not merged. Owner-only —
        editing a shared form's destination returns 403.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                type:
                  type: string
                  enum: [email, webhook, slack, sheets, zapier, crm]
                config:
                  type: object
                  additionalProperties: true
                enabled: { type: boolean }
      responses:
        "200":
          description: Updated destination
          content:
            application/json:
              schema:
                type: object
                required: [destination]
                properties:
                  destination: { $ref: "#/components/schemas/Destination" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      operationId: deleteDestination
      tags: [destinations]
      summary: Delete a destination
      description: Owner-only delete; a shared form's destination returns 403.
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/summary:
    get:
      operationId: summary
      tags: [summary]
      summary: Account-wide aggregates
      description: >-
        Totals, top five forms by submission count (full form DTOs), the eight
        most recent non-spam submissions across all forms, and a sparse
        14-day daily series.
      responses:
        "200":
          description: Aggregates
          content:
            application/json:
              schema:
                type: object
                required: [totalForms, activeForms, totalSubmissions, spamBlocked, topForms, recentSubmissions, byDay]
                properties:
                  totalForms: { type: integer }
                  activeForms: { type: integer }
                  totalSubmissions: { type: integer, format: int64 }
                  spamBlocked: { type: integer, format: int64 }
                  topForms:
                    type: array
                    maxItems: 5
                    items: { $ref: "#/components/schemas/Form" }
                  recentSubmissions:
                    type: array
                    maxItems: 8
                    items: { $ref: "#/components/schemas/Submission" }
                  byDay:
                    type: array
                    items: { $ref: "#/components/schemas/DaySubmissions" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }

  /v1/{any}:
    options:
      operationId: mgmtPreflight
      tags: [system]
      summary: Management CORS preflight catch-all
      description: >-
        Unauthenticated catch-all so browser preflights to any /v1 path get
        CORS headers instead of a bare 404.
      security: []
      parameters:
        - { name: any, in: path, required: true, schema: { type: string } }
      responses:
        "204":
          description: Preflight OK (empty body)

  /{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: ingest
      tags: [ingest]
      summary: Submit a form (public)
      description: >-
        The product itself. Accepts application/json (1 MiB cap),
        x-www-form-urlencoded, or multipart/form-data (files not stored; names
        joined into a synthetic _files field). Reserved fields _gotcha/_honey
        (honeypot) and cf-turnstile-response (also via X-Turnstile-Token
        header) are stripped. Spam-flagged submissions still return success.
        JSON clients (?ajax=1, Accept, or JSON content type) get
        {ok, id}; HTML posts get a 302 to redirectUrl when configured, else
        {ok}. Rate limit 60/min per form+IP.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
          application/x-www-form-urlencoded:
            schema:
              type: object
              additionalProperties: true
          multipart/form-data:
            schema:
              type: object
              additionalProperties: true
      responses:
        "200":
          description: Accepted ({ok, id} for JSON clients; {ok} for HTML posts without redirect)
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok: { type: boolean }
                  id: { type: string, description: Submission id (JSON clients only) }
        "302":
          description: HTML post with successBehavior redirect — Location is the form's redirectUrl
          headers:
            Location:
              schema: { type: string }
        "400":
          description: Could not parse submission
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: Origin not allowed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: Form not found or inactive (deliberately indistinguishable)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500":
          description: Could not save submission
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    options:
      operationId: ingestPreflight
      tags: [ingest]
      summary: Ingest CORS preflight (public)
      description: 204 with per-form CORS headers; 404/403 with empty bodies for unknown forms or disallowed origins.
      security: []
      responses:
        "204":
          description: Preflight OK (empty body)
        "403":
          description: Origin not allowed (empty body)
        "404":
          description: Form not found or inactive (empty body)
    get:
      operationId: ingestInfo
      tags: [ingest]
      summary: Endpoint hint (public)
      description: Unconditional helper response — the form is not looked up.
      security: []
      responses:
        "200":
          description: Usage hint
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  method: { type: string }

components:
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      bearerFormat: JWT
  schemas:
    Project:
      type: object
      required: [id, name, color, formCount, created, updated]
      properties:
        id: { type: string }
        name: { type: string }
        color: { type: string }
        formCount: { type: integer, description: Forms in the project the caller can read }
        visibility:
          type: string
          description: private = owner only · org = readable by everyone in the organization
        ownerId: { type: string }
        created: { type: string, format: date-time }
        updated: { type: string, format: date-time }
    Form:
      type: object
      required:
        [id, projectId, name, endpointUrl, targetEmails, allowedOrigins,
         successBehavior, honeypot, spamProvider, contentFilter, aiFilter,
         autoresponder, submissionCount, spamCount, active, created, updated]
      properties:
        id: { type: string, description: "6-char nanoid, not a UUID" }
        projectId: { type: string }
        name: { type: string }
        endpointUrl: { type: string }
        targetEmails:
          type: array
          items: { type: string }
        allowedOrigins:
          type: array
          items: { type: string }
        successBehavior: { type: string, description: json | redirect }
        redirectUrl: { type: string, nullable: true }
        honeypot: { type: boolean }
        spamProvider: { type: string, description: none | turnstile }
        contentFilter: { type: string, description: off | low | medium | high }
        aiFilter: { type: boolean }
        autoresponder:
          type: object
          required: [enabled, subject, body]
          properties:
            enabled: { type: boolean }
            subject: { type: string }
            body: { type: string }
        submissionCount: { type: integer, format: int64 }
        spamCount: { type: integer, format: int64 }
        active: { type: boolean }
        visibility:
          type: string
          description: private = owner only · org = readable by everyone in the organization
        ownerId: { type: string }
        created: { type: string, format: date-time }
        updated: { type: string, format: date-time }
    Submission:
      type: object
      required: [id, formId, data, isSpam, spamReason, ip, referrer, userAgent, created]
      properties:
        id: { type: string, format: uuid }
        formId: { type: string }
        data:
          type: object
          additionalProperties: { type: string }
          description: Ingest coerces every value to a string
        isSpam: { type: boolean }
        spamReason:
          type: string
          description: >-
            "" when clean; else e.g. "honeypot triggered", "failed CAPTCHA",
            comma-joined heuristics, or "AI: <reason>"
        ip: { type: string }
        referrer: { type: string }
        userAgent: { type: string }
        created: { type: string, format: date-time }
    Destination:
      type: object
      required: [id, type, config, enabled, created, updated]
      properties:
        id: { type: string }
        type: { type: string, description: email | webhook | slack | sheets | zapier | crm }
        config:
          type: object
          additionalProperties: true
          description: 'For email: {"to": ["a@b.com"]}'
        enabled: { type: boolean }
        created: { type: string, format: date-time }
        updated: { type: string, format: date-time }
    DaySubmissions:
      type: object
      required: [date, submissions]
      properties:
        date: { type: string, description: "UTC day, YYYY-MM-DD; sparse — zero days omitted" }
        submissions: { type: integer }
    Error:
      type: object
      required: [error]
      properties:
        error: { type: string }
  responses:
    BadRequest:
      description: Invalid or incomplete request body
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: >-
        Missing/invalid bearer token, or a token without org context
        ("token missing org context — refresh your session")
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ToolDisabled:
      description: The forms tool is turned off for the caller's organization
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: >-
        Either the forms tool is turned off for the caller's organization, or
        the row is readable only because a teammate shared it with the
        organization and mutations stay with its owner ("this form is shared
        with you — only its owner can change it")
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Resource not found or not owned by the caller
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ServerError:
      description: Internal error
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
