openapi: 3.0.3
info:
  title: Aptly API
  version: "1.1"
  description: |
    The Aptly API lets external systems work with boards, contacts, inboxes,
    tasks, files, and other Aptly resources.

    Most endpoints accept an API key in the `x-token` header. Some endpoints
    also accept a delegate token or partner bearer token, as shown in each
    operation's security requirements, and explicitly public endpoints require
    no credential. API keys are scoped to a company and may be restricted to
    specific boards and read, insert, or update permissions; requests outside
    those restrictions receive a 403 `FORBIDDEN` response.

servers:
  - url: https://core-api.getaptly.com
    description: Production

security:
  - ApiKeyHeader: []

components:
  securitySchemes:
    ApiKeyHeader:
      type: apiKey
      in: header
      name: x-token
    DelegateToken:
      type: apiKey
      in: header
      name: Authorization
      description: "Delegate token issued by the platform. Format: `DelegateToken <token>`"
    PartnerBearer:
      type: http
      scheme: bearer
      description: "Partner token. Format: `Authorization: Bearer <token>`"

  schemas:
    KnowledgeHistoryVersion:
      type: object
      properties:
        versionId: &a2
          type: string
        name: &a1
          type: string
          nullable: true
        savedAt: &a3
          type: string
          format: date-time
          nullable: true
        savedBy: *a1
        savedByName: *a1
        conduit:
          type: string
          nullable: true
          description: hiro identifies a save through Hiro. Saver attribution is not proof of passage authorship.
        emoji: *a1
        iconUrl: *a1
        iconFileId: *a1
        coverId: *a1
        coverUrl: *a1
    KnowledgeHistoryList:
      type: object
      properties:
        documentId: *a2
        name: *a2
        note: *a2
        nextOffset: &a4
          type: integer
          nullable: true
          description: Pass as offset to continue; null means this result is complete.
        current:
          type: object
          properties:
            updatedAt: *a3
            updatedBy: *a1
        versions:
          type: array
          items:
            $ref: "#/components/schemas/KnowledgeHistoryVersion"
    KnowledgeHistoryDetail:
      type: object
      properties:
        documentId: *a2
        name: *a2
        note: *a2
        nextOffset: *a4
        snapshot:
          $ref: "#/components/schemas/KnowledgeHistoryVersion"
        readOnly:
          type: boolean
          enum:
            - true
        total:
          type: integer
          description: Total top-level blocks in this snapshot.
        blocks:
          type: array
          maxItems: 20
          items:
            type: object
            properties:
              id: *a2
              type: *a2
              text:
                type: string
                maxLength: 10000
              truncated:
                type: boolean
                description: Block text exceeded 10,000 characters; the rest is not returned.
              commented:
                type: boolean
    BoardFilterRule:
      type: object
      properties:
        fieldUuid:
          type: string
          description: "Field uuid from the board catalog (HIRO: get_board_fields.fields); system fields include assignee, stage, updatedAt."
        key:
          type: string
          description: Exact filter key from the field's filters array. Required to select a subfield when one uuid has multiple filters.
        operator:
          type: string
          description:
            Supported operator from the filter's operatorSet in the catalog's operatorSets dictionary. Greater-than is spelled gratherThan for saved-rule
            compatibility.
        value:
          description: >-
            between takes [minimum, maximum], e.g. ["2026-09-28", "2026-10-04"] for ISO dates or [0, 100] for numbers.
            Money values are {amount: cents, currency?: string}; money ranges use two such objects.
            anyIn/notIn require nonempty arrays of option values, user ids, booleans/null, or contact {_id} objects as appropriate for the field.
            Use option.value when present, otherwise the literal option string/label.
            Contact equal uses {_id}. Date comparisons use ISO dates; specificPeriod uses {period: dayOfYear|month|quarter|year|week, adjustment?: integer}.
            For feeAmount fields, wrap any supplied value in {end: value}, including {end: [minimum, maximum]} for between.
            Omit value for noValue operators.
        valueSource:
          type: string
          enum:
            - currentUser
          description: For user anyIn/notIn rules, resolves the authenticated/selected user on the server. Omit value.
        valueFieldUuid:
          type: string
          description: Optional comparison field uuid or catalog key; equal, notEqual, lessThan, gratherThan only. Omit value.
        defaultValueField:
          type: string
          description: Optional catalog field/key used if the filtered field is missing.
      required:
        - fieldUuid
        - operator
    BoardCardListRequest:
      type: object
      required:
        - page
      properties:
        page:
          type: integer
          minimum: 0
          maximum: 9999
          description: Zero-based page number.
        pageSize:
          type: integer
          minimum: 1
          maximum: 1000
          default: 20
          description: Number of cards per page.
        updatedAtMin:
          type: string
          format: date-time
          description: Only return cards updated after this ISO timestamp.
        includeArchived:
          type: boolean
          default: false
          description:
            Include archived cards. If omitted, an explicit archived rule controls archive selection;
            otherwise active cards only. An explicit false intersects with the rules.
        relatedId:
          type: string
          description: Only return cards where `references.value` contains this ID.
        contactEmail:
          type: string
          description: Resolves the email to contact IDs, then filters cards referencing those contacts.
        keyTerm:
          type: string
          description: Full-text autocomplete search on card title using the Atlas Search index.
        assignee:
          type: string
          description: Filter cards by assignee user ID.
        segmentId:
          type: string
          maxLength: 256
          description: Existing accessible, active segment on this board; mutually exclusive with rules.
        rules:
          type: array
          maxItems: 100
          items:
            $ref: "#/components/schemas/BoardFilterRule"
          description:
            Ad hoc saved-segment rules, ANDed together. Requires an explicit board. Supply rules OR
            segmentId. Invalid rules are rejected.
        userId:
          type: string
          maxLength: 256
          description:
            Active company member whose private segments/currentUser values to use. API keys/partners may
            select a member; delegates cannot select another user.
      description:
        Read-only card listing. Supply segmentId OR rules; existing simple filters intersect with either.
        Segment/rule queries exclude deleted cards unless explicitly selected by a rule. No filter or card is
        saved. Saved segments must contain a rules array; missing or malformed rules require re-saving.
        An explicitly empty rules array matches all cards within the remaining filters.
    ContactConversationsResponse:
      type: object
      required:
        - personIds
        - conversations
        - hasMore
        - skip
        - limit
      properties:
        personIds:
          type: array
          items:
            type: string
        conversations:
          type: array
          items:
            type: object
            required:
              - _id
              - subject
              - description
              - messageCount
            properties:
              _id:
                type: string
                description: Conversation stream ID.
              subject:
                type: string
                nullable: true
              description:
                type: string
                maxLength: 500
              type:
                type: string
              status:
                type: integer
                description: 0 closed, 1 open, 2 waiting, 3 replied.
              assigneeId:
                type: string
                nullable: true
              participants:
                type: array
                items:
                  type: string
              channel:
                type: object
                properties:
                  id:
                    type: string
                  type:
                    type: string
              messageCount:
                type: integer
              metrics:
                type: object
                properties:
                  lastMessageAt:
                    type: string
                    format: date-time
                  lastActivityAt:
                    type: string
                    format: date-time
                    nullable: true
                  lastMessageDirection:
                    type: string
                    enum:
                      - in
                      - out
                    nullable: true
        hasMore:
          type: boolean
          description: When true, request the next page with skip + limit.
        skip:
          type: integer
        limit:
          type: integer

    Card:
      type: object
      properties:
        cardId:
          type: string
          description: Unique ID of the card.
        boardUuid:
          type: string
          description: UUID of the board this card belongs to.
        archived:
          type: boolean
        assignee:
          type: string
          description: Full name of the assigned user.
        lastActivity:
          type: object
          nullable: true
          description: >-
            Most recent conversation activity on the card (email, SMS or call),
            or null if the card has none.
          properties:
            content:
              type: string
              nullable: true
              description: Body/text preview of the most recent message.
            type:
              type: string
              nullable: true
              description: Activity type, e.g. `email`, `sms`, `voice`.
            direction:
              type: string
              nullable: true
              enum: [in, out]
              description: Direction of the message — `in` (inbound) or `out` (outbound).
            publishedAt:
              type: string
              format: date-time
              nullable: true
            conversationUrl:
              type: string
              nullable: true
              description: >-
                Deep link to the conversation thread in Aptly. Null when the
                activity is not attached to a thread.
      additionalProperties:
        description: >-
          Additional properties are dynamic board fields keyed by their field
          UUID. Fields of type `files` come back as an array of file objects and
          type `file` as a single file object — `{ fileId, name, size, type,
          url, expiresAt }`, where `url` is a presigned download link valid
          until `expiresAt` (1 hour, or sooner if the signing session ends
          first). An id with no readable file record degrades to
          `{ fileId }` with no `url`.

    InboxThreadMetrics:
      type: object
      description: >-
        Stored metrics for one thread. Response times are in business hours, using
        the company's configured business hours and holidays.
      properties:
        firstResponseHrs:
          type: number
          nullable: true
          description: Time to the first reply. Null when nobody has replied.
        avgResponseHrs:
          type: number
          nullable: true
        responseCount:
          type: integer
          description: Replies the averages were computed from.
        reopened:
          type: boolean
          description: True when an inbound arrived after the thread was closed.
        touchCount:
          type: integer
          description: Total messages in the thread, both directions.
        inboundCount:
          type: integer
        outboundCount:
          type: integer
        lastMessageAt:
          type: string
          format: date-time
          nullable: true
        lastMessageDirection:
          type: string
          nullable: true
          enum: [in, out]

    InboxMessage:
      type: object
      properties:
        messageId:
          type: string
        threadId:
          type: string
        subject:
          type: string
          nullable: true
        body:
          type: string
          nullable: true
        type:
          type: string
          nullable: true
          description: e.g. `email`, `sms`, `voice`.
        timestamp:
          type: string
          format: date-time
        direction:
          type: string
          enum: [in, out]
        from:
          type: object
          nullable: true
          description: Sender, as `{ id, name }` where `id` is an email address or phone number.
        to:
          type: array
          items:
            type: object
          description: Recipients, each as `{ id, name }`.
        sentByUser:
          type: string
          nullable: true
          description: Aptly user id that sent an outbound, when known.
        sentVia:
          type: string
          nullable: true
          enum: [aptly, native]
          description: >-
            How an outbound message was sent: `aptly` when it was composed in Aptly,
            `native` when the user sent it from their own mail client and it synced
            in. Always `null` on inbound messages.

    InboxMessageAttachment:
      type: object
      properties:
        id:
          type: string
          description: >-
            Provider attachment id; pass it to the attachment download endpoint.
            Gmail can re-issue this id between reads, so download soon after
            reading the message.
        name:
          type: string
          nullable: true
        contentType:
          type: string
          nullable: true
        size:
          type: integer
          nullable: true
          description: Size in bytes, as reported by the provider.
        isInline:
          type: boolean
          description: True for images embedded in the body.
        contentId:
          type: string
          nullable: true
          description: Matches `cid:` references in an HTML body.
    InboxThread:
      type: object
      properties:
        threadId:
          type: string
        subject:
          type: string
          nullable: true
        status:
          type: string
          nullable: true
          enum: [closed, new, open, sent]
          description: >-
            The conversation's status, mapped from the stream's numeric `status`
            field (0 closed, 1 new, 2 open, 3 sent). `null` when the thread has
            no status, or an unrecognized one.
        isJunk:
          type: string
          nullable: true
          enum: [archived, not_junk, junk]
          description: >-
            The thread's junk state, mapped from the stream's numeric `isJunk`
            field (-1 archived, 0 not junk, 1 junk). `null` when the thread has
            no value, or an unrecognized one. Junk threads are excluded from the
            listing unless `includeJunk=true`.
        unread:
          type: boolean
          description: >-
            Whether the thread is marked unread, from the stream's 0/1 `unread`
            flag. Absent on the stream means read.
        assigneeId:
          type: string
          nullable: true
          description: Assigned Aptly user id, or null when unassigned.
        type:
          type: string
          nullable: true
        participants:
          type: array
          items:
            type: string
          description: Email addresses / phone numbers taking part in the thread.
        labels:
          type: array
          description: >-
            Folder labels applied to the thread, each as `{ id, name }`. Only real
            folder labels are returned — the encoded scoping ids also stored on a
            thread (channel, assignee, mention, person type, category) are omitted.
          items:
            type: object
            properties:
              id:
                type: string
              name:
                type: string
                nullable: true
        metrics:
          $ref: "#/components/schemas/InboxThreadMetrics"
        messages:
          type: array
          items:
            $ref: "#/components/schemas/InboxMessage"

    InboxMetricSet:
      type: object
      description: >-
        Averages and rates are null (not 0) when the window has nothing to measure.
      properties:
        threadCount:
          type: integer
        contactCount:
          type: integer
          description: Distinct counterparts across the matching threads.
        volume:
          type: object
          properties:
            in:
              type: integer
            out:
              type: integer
            total:
              type: integer
        avgFirstResponseHrs:
          type: number
          nullable: true
        avgResponseHrs:
          type: number
          nullable: true
        responseCount:
          type: integer
          description: Replies the averages were computed from.
        oneTouchPct:
          type: number
          nullable: true
          description: Percentage of threads resolved with a single outbound.
        oneTouchCount:
          type: integer
        reopenRatePct:
          type: number
          nullable: true
        reopenedCount:
          type: integer
        noCustomerResponseCount:
          type: integer
          description: Threads where we sent something and never got a reply.
        awaitingOurReplyCount:
          type: integer
          description: Open threads where the counterpart spoke last.
        sentiment:
          $ref: "#/components/schemas/InboxSentiment"

    InboxSentiment:
      type: object
      description: >-
        AI sentiment for the window. Scores are produced on write by the
        enhancement pipeline, only for inboxes where it is switched on, so
        `coverage` is always reported next to the average — an average over a
        fraction of the threads must not be read as covering all of them.
      properties:
        enabled:
          type: boolean
          description: >-
            Whether sentiment scoring is currently on for this inbox (org AI
            assistant + org sentiment + the inbox's own feature flag). Reported at
            the inbox level only, not per group.
        avg:
          type: number
          nullable: true
          description: Mean score across scored threads. Null when none were scored.
        coverage:
          type: object
          properties:
            scoredThreads:
              type: integer
            threadCount:
              type: integer
            pct:
              type: number
              nullable: true

    InboxTopic:
      type: object
      description: >-
        A topic (category) the enhancement pipeline or a teammate applied to
        threads in the window. Topics the company no longer defines are omitted.
      properties:
        topicId:
          type: string
        label:
          type: string
          nullable: true
        emoji:
          type: string
          nullable: true
        color:
          type: string
          nullable: true
        threadCount:
          type: integer

    InboxHealth:
      type: object
      properties:
        window:
          type: object
          properties:
            from:
              type: string
              format: date-time
            to:
              type: string
              format: date-time
        baseline:
          type: object
          properties:
            from:
              type: string
              format: date-time
            to:
              type: string
              format: date-time
            periods:
              type: integer
            avgVolume:
              type: number
              nullable: true
              description: Mean volume per period across the baseline span.
            sufficient:
              type: boolean
              description: >-
                False when the baseline span carried no volume. Everything derived
                from the baseline is null in that case.
        volume:
          type: integer
          description: Messages in the requested window.
        volumeVsBaselinePct:
          type: number
          nullable: true
          description: >-
            Percentage change against the baseline average. Negative is a decline.
            Null when `baseline.sufficient` is false.
        trend:
          type: string
          enum: [falling, rising, flat, unknown]
          description: >-
            Second half of the window vs. the first. `unknown` when the window is
            too short to split.
        severityScore:
          type: integer
          nullable: true
          description: 0–100, tracking decline magnitude. 0 for flat or growing volume.
        severityLabel:
          type: string
          enum: [critical, high, moderate, low, none, insufficient_history]
        tags:
          type: array
          items:
            type: string
          description: >-
            Human-readable flags, e.g. `Usage down`, `No activity`, `Still falling`.

    InboxTrendPeriod:
      type: object
      properties:
        period:
          type: string
          description: >-
            Period key. `YYYY-MM-DD` for day and week (week = the Monday it starts),
            `YYYY-MM` for month, `YYYY-Qn` for quarter.
        volume:
          type: object
          description: Messages sent or received during this period.
          properties:
            in:
              type: integer
            out:
              type: integer
            total:
              type: integer
        threadCount:
          type: integer
          description: Threads whose last activity fell in this period.
        avgResponseHrs:
          type: number
          nullable: true
          description: Business hours. Null when the period had no replies.
        responseCount:
          type: integer
          description: Replies behind avgResponseHrs.
        avgFirstResponseHrs:
          type: number
          nullable: true
        oneTouchPct:
          type: number
          nullable: true
        reopenRatePct:
          type: number
          nullable: true

    InboxAnalytics:
      allOf:
        - $ref: "#/components/schemas/InboxMetricSet"
        - type: object
          properties:
            window:
              type: object
              properties:
                from:
                  type: string
                  format: date-time
                  nullable: true
                to:
                  type: string
                  format: date-time
                  nullable: true
            topics:
              type: array
              items:
                $ref: "#/components/schemas/InboxTopic"
            groupBy:
              type: string
              nullable: true
              enum: [assignee, contactType]
            groups:
              type: array
              description: Present only when `groupBy` was supplied.
              items:
                allOf:
                  - type: object
                    properties:
                      key:
                        type: string
                        nullable: true
                        description: Assignee user id or contact-type id. Null for unassigned.
                      label:
                        type: string
                        nullable: true
                  - $ref: "#/components/schemas/InboxMetricSet"

    SchemaField:
      type: object
      properties:
        key:
          type: string
          description: Field UUID — use this as the key when reading/writing card data.
        label:
          type: string
          description: Human-readable field name.
        type:
          type: string
          description: Field type (e.g. text, date, money, multiselect, etc.)

    Comment:
      type: object
      properties:
        id:
          type: string
        userId:
          type: string
        content:
          type: string
        createdAt:
          type: string
          format: date-time

    TabView:
      type: object
      properties:
        uuid:
          type: string
        name:
          type: string
        url:
          type: string
        icon:
          type: string
        embedSource:
          type: string
        type:
          type: string

    Contact:
      type: object
      properties:
        _id:
          type: string
          description: MongoDB ObjectId of the contact.
        uuid:
          type: string
        firstname:
          type: string
        lastname:
          type: string
        fullName:
          type: string
          description: Computed display name (first + last, or company name for org records).
        duogram:
          type: string
          description: Two-letter initials derived from first and last name.
        photoId:
          type: string
          nullable: true
        imageUrl:
          type: string
          nullable: true
          description: CDN thumbnail URL for the contact's photo, or null if none.
        email:
          type: string
        phone:
          type: string
        typeId:
          type: string
        companyId:
          type: string
        address:
          type: object
        title:
          type: string
        alerts:
          type: array
          items:
            type: object
        company:
          type: string
          description: Company name — populated when isCompany is true.
        isCompany:
          type: boolean

    KnowledgeDocSummary:
      type: object
      description: Lite shape returned by the list/tree/children knowledge endpoints (no rendered HTML).
      properties:
        _id:
          type: string
          description: Document ID.
        name:
          type: string
          description: Document title.
        emoji:
          type: string
          nullable: true
        iconUrl:
          type: string
          nullable: true
        accessType:
          type: string
          enum: [public, publicReadonly, private]
        parentId:
          type: string
          nullable: true
          description: Parent document ID, or null for a root document.
        rank:
          type: integer
          nullable: true
          description: Sort order among siblings. May be null.
        archived:
          type: boolean

    KnowledgeTreeNode:
      allOf:
        - $ref: "#/components/schemas/KnowledgeDocSummary"
        - type: object
          properties:
            hasChildren:
              type: boolean
              description: Whether this document has at least one child, whether or not it was expanded.
            matched:
              type: boolean
              description: >
                Only present when at least one of the tree endpoint's filter parameters
                (aptletUuid, aptletInstanceId, streamId, search, archived, ownerId,
                updatedById, createdFrom, createdTo, updatedFrom, updatedTo, unlinked) is
                active. `true` for a document that directly matched the filters; `false`
                for a node kept only as ancestor/descendant context around a match.
            children:
              type: array
              items:
                $ref: "#/components/schemas/KnowledgeTreeNode"
              description: >
                Nested nodes, present up to the requested `depth`. When a filter is
                active, this is pruned to matches plus their ancestors/descendants within
                the tree rather than every child.

    KnowledgeDoc:
      type: object
      description: Full document detail returned by GET /api/knowledge/{id}.
      properties:
        _id:
          type: string
          description: Document ID.
        name:
          type: string
          description: Document title.
        emoji:
          type: string
          nullable: true
        iconUrl:
          type: string
          nullable: true
        coverUrl:
          type: string
          nullable: true
        accessType:
          type: string
          enum: [public, publicReadonly, private]
        isPrivate:
          type: boolean
        shares:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
              type:
                type: string
                enum: [user, team]
              permission:
                type: string
                enum: [edit, comment, view]
              owner:
                type: boolean
              createdAt:
                type: string
                format: date-time
                nullable: true
              updatedAt:
                type: string
                format: date-time
                nullable: true
        parentId:
          type: string
          nullable: true
        path:
          type: array
          description: Root-first ancestor chain (folders above this document). Excludes this document.
          items:
            type: object
            properties:
              _id:
                type: string
              name:
                type: string
              emoji:
                type: string
                nullable: true
        children:
          type: array
          items:
            type: object
            properties:
              _id:
                type: string
              name:
                type: string
              emoji:
                type: string
                nullable: true
              rank:
                type: integer
                nullable: true
        childCount:
          type: integer
        links:
          type: object
          properties:
            aptletUuid:
              type: string
              nullable: true
            aptletInstanceId:
              type: string
              nullable: true
            streamId:
              type: string
              nullable: true
        published:
          type: boolean
        publishedAt:
          type: string
          format: date-time
          nullable: true
        views:
          type: object
          properties:
            internal:
              type: integer
            public:
              type: integer
        archived:
          type: boolean
        archivedAt:
          type: string
          format: date-time
          nullable: true
        createdAt:
          type: string
          format: date-time
          nullable: true
        createdBy:
          type: string
          nullable: true
        updatedAt:
          type: string
          format: date-time
          nullable: true
        updatedBy:
          type: string
          nullable: true
        currentUser:
          type: object
          nullable: true
          description: >
            Null for API key / partner token callers — those credentials are company-wide,
            so no per-user permission check is applied. Populated only for a delegate token,
            where the caller's own user identity is known.
          properties:
            userId:
              type: string
            isOwner:
              type: boolean
            permission:
              type: string
              nullable: true
              enum: [edit, comment, view, null]
            canView:
              type: boolean
            canEdit:
              type: boolean
            canComment:
              type: boolean
        html:
          type: string
          description: >-
            Document body rendered as HTML. Aptly custom blocks and inline items appear as
            elements with `data-content-type` / `data-inline-content-type` and `data-*`
            property attributes, plus a readable label — see "HTML format" in
            `PUT /api/knowledge/{id}`.

    EmailRecipient:
      type: object
      required: [value]
      properties:
        value:
          type: string
          description: Email address.
        label:
          type: string
          description: Optional display name for the recipient.

    Board:
      type: object
      properties:
        uuid:
          type: string
          description: Board UUID — use this as `boardId` in board/card endpoints.
        name:
          type: string
          description: Display name of the board.
        endpoints:
          type: array
          description: Pre-built endpoint URLs for this board.
          items:
            type: object
            properties:
              method:
                type: string
                description: HTTP method.
              url:
                type: string
                description: Full endpoint URL.
              description:
                type: string
                description: What the endpoint does.

    NewBoardFieldType:
      type: string
      description: |
        The field types that can be created through the API (`POST /api/boards` and
        `POST /api/board/{boardId}/configuration/fields`). The value column is what a card stores
        and what the card API (`POST /api/board/{boardId}`) accepts under the field's `uuid`.

        | `type` | Label | Card value |
        |---|---|---|
        | `string` | Short Text | string |
        | `text` | Long Text | string |
        | `rich-text` | Rich Text | string (HTML) |
        | `number` | Number | number |
        | `money` | Currency | decimal number in requests (`1500.5`); returned as `{ amount, currency }` |
        | `percent` | Percent | number |
        | `multiplier` | Multiplier | number |
        | `boolean` | Checkbox | boolean |
        | `date` | Date | date |
        | `datetime-local` | Date and Time | date |
        | `email` | Email | string |
        | `emails` | Multiple Emails | string[] |
        | `tel` | Phone Number | string |
        | `phones` | Multiple Phone Numbers | array of phone objects |
        | `url` | URL | string |
        | `address` | Address | address object |
        | `person` | Link to a Single Contact | `{ _id }` (or `null` to clear); the contact must exist |
        | `persons` | Link to Multiple Contacts | array of `{ _id }` |
        | `user` | Team Member | user id |
        | `file` | Single Attachment | file id |
        | `files` | Multiple Attachments | file ids |
        | `heroImage` | Cover Photo | file id (the card's Kanban thumbnail) |
        | `embedVideo` | Embed Video | video URL string |
        | `stringList` | Text List | string[] |
        | `tags` | Tags | string[] |
        | `singleselect` | Dropdown | the chosen option's label - **requires `data`** |
        | `multiselect` | Multiple Select | array of option labels - **requires `data`** |
        | `checkboxlist` | Task List | array of `{ _id, checked, title }` (no `data`) |

        Relational and system types (`relatedAptlets`, `mirror`, `location`, `unit`, `stageselect`,
        `calculation`, and similar) can't be created through the API; boards built in the Aptly app
        can still contain them and they appear when reading fields.
      enum:
        - string
        - text
        - rich-text
        - number
        - money
        - percent
        - multiplier
        - boolean
        - date
        - datetime-local
        - email
        - emails
        - tel
        - phones
        - url
        - address
        - person
        - persons
        - user
        - file
        - files
        - heroImage
        - embedVideo
        - stringList
        - tags
        - singleselect
        - multiselect
        - checkboxlist
    AutomationTrigger:
      description: What starts the automation. Exactly one `type`.
      oneOf:
        - $ref: "#/components/schemas/AutomationStageTrigger"
        - $ref: "#/components/schemas/AutomationSegmentTrigger"
        - $ref: "#/components/schemas/AutomationReplyTrigger"
      discriminator:
        propertyName: type
        mapping:
          stage: "#/components/schemas/AutomationStageTrigger"
          segment: "#/components/schemas/AutomationSegmentTrigger"
          reply: "#/components/schemas/AutomationReplyTrigger"
    AutomationStageTrigger:
      type: object
      additionalProperties: false
      required: [type, stage]
      properties:
        type: { type: string, enum: [stage] }
        stage:
          type: string
          description: A stage of the board, spelled exactly as it is on the board (case and spacing included). A stage trigger fires at most once per card.
    AutomationSegmentTrigger:
      type: object
      additionalProperties: false
      required: [type, conditions]
      properties:
        type: { type: string, enum: [segment] }
        conditions:
          type: array
          minItems: 1
          maxItems: 25
          description: |
            Rules a card must meet, ANDed together - the same rule format as board segments (`fieldUuid`,
            `operator`, `value`, ...; see `GET /api/board/{boardId}/segments`). At least one rule is
            required, because an automation with no conditions would run on every card. A rule on an
            unknown field, an operator the field doesn't support, a missing or malformed value, a stage or
            option that isn't on the board, or a user who isn't in the company is rejected, as are rules
            on `archived`/`deleted` and `valueSource: currentUser` (an automation runs without a signed-in
            user). The rules are saved as a segment named `Automation - <title>`.
          items:
            type: object
        frequency:
          type: string
          enum: [everyTime, once]
          default: everyTime
          description: "`once` fires the first time a card matches; `everyTime` fires again each time it stops and starts matching."
    AutomationReplyTrigger:
      type: object
      additionalProperties: false
      required: [type]
      properties:
        type: { type: string, enum: [reply] }
        stages:
          type: array
          maxItems: 50
          default: []
          description: Only fire for cards in these stages. Empty means any stage.
          items: { type: string }
        frequency: { type: string, enum: [everyTime, once], default: everyTime }
        audience:
          type: string
          enum: [anyone, relatedContacts]
          default: anyone
          description: "`relatedContacts` requires the sender to be a contact related to the card."
        reopenArchived:
          type: boolean
          default: false
          description: Reopen an archived card when a reply arrives.
    AutomationTiming:
      description: |
        When the actions run after the trigger. Defaults to `{ "mode": "now" }`. A trigger with
        `frequency: once` can only use `now` or `after`. Times of day are hours (0-23) in the
        organization's time zone, and the automation fires within four hours after that time.
      oneOf:
        - type: object
          additionalProperties: false
          required: [mode]
          properties:
            mode: { type: string, enum: [now] }
            useBusinessHours:
              type: boolean
              default: false
              description: Only fire while the board or organization is open. Rejected when no business hours are configured, because it would never run.
        - type: object
          additionalProperties: false
          required: [mode, amount, unit]
          properties:
            mode: { type: string, enum: [after] }
            amount: { type: integer, minimum: 1, maximum: 100000 }
            unit: { type: string, enum: [minutes, hours, days, months] }
            anchor:
              type: string
              default: stageUpdatedAt
              description: What the delay counts from - `stageUpdatedAt`, `conditionMet`, `createdAt`, or the uuid of a date field on the board.
            timeOfDay:
              type: integer
              minimum: 0
              maximum: 23
              description: Only for `days` or `months`.
            useBusinessHours:
              type: boolean
              default: false
              description: Only for `minutes` or `hours`.
        - type: object
          additionalProperties: false
          required: [mode, day, timeOfDay]
          properties:
            mode: { type: string, enum: [dayOfWeek] }
            day:
              {
                type: string,
                enum:
                  [
                    sunday,
                    monday,
                    tuesday,
                    wednesday,
                    thursday,
                    friday,
                    saturday,
                  ],
              }
            timeOfDay: { type: integer, minimum: 0, maximum: 23 }
        - type: object
          additionalProperties: false
          required: [mode, days, timeOfDay]
          properties:
            mode: { type: string, enum: [daysOfWeek] }
            days:
              type: array
              minItems: 1
              maxItems: 7
              description: 0 = Sunday ... 6 = Saturday. No repeats.
              items: { type: integer, minimum: 0, maximum: 6 }
            timeOfDay: { type: integer, minimum: 0, maximum: 23 }
        - type: object
          additionalProperties: false
          required: [mode, day, timeOfDay]
          properties:
            mode: { type: string, enum: [dayOfMonth] }
            day:
              {
                type: integer,
                minimum: 1,
                maximum: 31,
                description: 31 means the last day of the month.,
              }
            timeOfDay: { type: integer, minimum: 0, maximum: 23 }
        - type: object
          additionalProperties: false
          required: [mode, startDay, endDay, timeOfDay]
          properties:
            mode: { type: string, enum: [dateRange] }
            startDay: { type: integer, minimum: 1, maximum: 31 }
            endDay:
              {
                type: integer,
                minimum: 1,
                maximum: 31,
                description: Must be after startDay.,
              }
            timeOfDay: { type: integer, minimum: 0, maximum: 23 }
        - type: object
          additionalProperties: false
          required: [mode, interval]
          properties:
            mode: { type: string, enum: [everyXDays] }
            interval: { type: integer, minimum: 1, maximum: 365 }
            startImmediately: { type: boolean, default: true }
            timeOfDay: { type: integer, minimum: 0, maximum: 23 }
      discriminator:
        propertyName: mode
    AutomationRecipient:
      description: Who an email or SMS goes to.
      oneOf:
        - type: object
          additionalProperties: false
          required: [type]
          properties: { type: { type: string, enum: [assignee] } }
        - type: object
          additionalProperties: false
          required: [type]
          properties:
            type: { type: string, enum: [relatedContacts] }
        - type: object
          additionalProperties: false
          required: [type, contactTypeIds]
          properties:
            type: { type: string, enum: [contactTypes] }
            contactTypeIds:
              type: array
              minItems: 1
              maxItems: 25
              description: Contact type ids of the organization. Related contacts of these types receive the message.
              items: { type: string }
        - type: object
          additionalProperties: false
          required: [type, addresses]
          properties:
            type: { type: string, enum: [specific] }
            addresses:
              type: array
              minItems: 1
              maxItems: 25
              description: Email addresses for an email, E.164 phone numbers (+15551234567) for an SMS. No repeats.
              items: { type: string }
        - type: object
          additionalProperties: false
          required: [type, fieldUuid]
          properties:
            type: { type: string, enum: [field] }
            fieldUuid:
              type: string
              description: An email, user or contact field of the board (an SMS needs a user or contact field).
      discriminator:
        propertyName: type
    AutomationAction:
      description: |
        One thing the automation does. Actions run in order. At most one `stage`, one `due_date` and one
        archive action per automation. Every id, stage and field named here must exist on the board /
        organization or the whole request is rejected.
      oneOf:
        - type: object
          additionalProperties: false
          required: [type, inbox, templateId, to]
          description: Send an email.
          properties:
            type: { type: string, enum: [email] }
            inbox:
              {
                type: string,
                description: 'The sending inbox id, or "assignee" for the card assignee''s inbox. Must be an active email inbox with users.',
              }
            templateId:
              {
                type: string,
                description: An active email template of the organization.,
              }
            to: { $ref: "#/components/schemas/AutomationRecipient" }
            cc:
              type: array
              maxItems: 10
              default: []
              items: { type: string, format: email }
            deliveryMode:
              type: string
              enum: [multiple, single]
              default: multiple
              description: "`single` sends one email to all recipients; `multiple` sends each their own."
            fallback:
              type: object
              additionalProperties: false
              required: [inbox, templateId]
              description: Send an SMS instead to recipients with no email address. Needs an active phone inbox and an SMS template.
              properties:
                inbox: { type: string }
                templateId: { type: string }
        - type: object
          additionalProperties: false
          required: [type, inbox, templateId, to]
          description: Send a text message.
          properties:
            type: { type: string, enum: [sms] }
            inbox:
              {
                type: string,
                description: 'The sending phone inbox id, or "assignee".',
              }
            templateId:
              {
                type: string,
                description: An active SMS template of the organization.,
              }
            to: { $ref: "#/components/schemas/AutomationRecipient" }
            fallback:
              type: object
              additionalProperties: false
              required: [inbox, templateId]
              description: Send an email instead to recipients with no phone number.
              properties:
                inbox: { type: string }
                templateId: { type: string }
        - type: object
          additionalProperties: false
          required: [type, source]
          description: Create tasks on the card, from a task template or defined inline.
          properties:
            type: { type: string, enum: [task] }
            source: { type: string, enum: [template, manual] }
            templateId:
              {
                type: string,
                description: "Required for `template`. A task template of this board.",
              }
            tasks:
              type: array
              minItems: 1
              maxItems: 20
              description: Required for `manual`.
              items:
                type: object
                additionalProperties: false
                required: [title]
                properties:
                  title: { type: string, maxLength: 200 }
                  description: { type: string, maxLength: 2000 }
                  userId:
                    {
                      type: string,
                      description: An active user of the organization. Defaults to the card assignee.,
                    }
                  dueInDays: { type: integer, minimum: 0, maximum: 365 }
                  dueFrom:
                    {
                      type: string,
                      description: '"now" (default) or a date field uuid. Needs dueInDays.',
                    }
            fieldUuid:
              {
                type: string,
                description: A task list (checkboxlist) field to put the tasks on. Defaults to the card's checklist.,
              }
            useBusinessDays:
              {
                type: boolean,
                default: false,
                description: "Count the tasks' due dates in business days. Rejected when no business hours are configured, because the due dates would fall on calendar days instead.",
              }
        - type: object
          additionalProperties: false
          required: [type, mode]
          description: Assign the card.
          properties:
            type: { type: string, enum: [assign] }
            mode: { type: string, enum: [user, team, roundRobin, contactField] }
            userId: { type: string, description: "Required for `user`." }
            teamId:
              {
                type: string,
                description: "Required for `team`. An active team.",
              }
            contactFieldUuid:
              {
                type: string,
                description: "Required for `contactField`. A contact or user field of the board.",
              }
            onlyIfUnassigned: { type: boolean, default: false }
        - type: object
          additionalProperties: false
          required: [type, fields]
          description: |
            Set field values. Supported field types and the value each takes: `string`/`text` (text),
            `email`, `url` (http/https), `tel` (E.164), `number`, `percent` (a fraction, 0.25 = 25%),
            `money` (`{ amount: whole cents, currency?: "USD" }`), `boolean`, `singleselect` (an option),
            `multiselect` (array of options), `tags` (added to the existing tags), `user` (a user id), and
            `date`/`datetime-local` (`{ from: "now" | <date field uuid>, days: integer }` or
            `{ clear: true }`). Read-only and computed fields, and every other field type, are rejected.
          properties:
            type: { type: string, enum: [update_fields] }
            fields:
              type: array
              minItems: 1
              maxItems: 20
              description: Each field at most once.
              items:
                type: object
                additionalProperties: false
                required: [fieldUuid, value]
                properties:
                  fieldUuid: { type: string }
                  value: {}
        - type: object
          additionalProperties: false
          required: [type, stage]
          description: Move the card to a stage of the board. Can't be the stage that triggered a stage automation.
          properties:
            type: { type: string, enum: [stage] }
            stage: { type: string }
        - type: object
          additionalProperties: false
          required: [type]
          description: Set or clear the card's due date.
          properties:
            type: { type: string, enum: [due_date] }
            from:
              {
                type: string,
                default: now,
                description: '"now", "clear", or a date field uuid.',
              }
            days:
              {
                type: integer,
                minimum: -366,
                maximum: 1999,
                description: Required unless `from` is "clear".,
              }
            useBusinessDays:
              {
                type: boolean,
                description: 'Count `days` in business days. Not valid when `from` is "clear". Rejected when no business hours are configured, because the due date would fall on calendar days instead.',
              }
        - type: object
          additionalProperties: false
          required: [type]
          description: Post a notification comment on the card. At least one of userIds, teamIds, fieldUuids or notifyCardCreator is required.
          properties:
            type: { type: string, enum: [notify] }
            userIds:
              {
                type: array,
                maxItems: 25,
                default: [],
                items: { type: string },
              }
            teamIds:
              {
                type: array,
                maxItems: 25,
                default: [],
                items: { type: string },
              }
            fieldUuids:
              {
                type: array,
                maxItems: 10,
                default: [],
                description: User fields of the board.,
                items: { type: string },
              }
            notifyCardCreator: { type: boolean, default: false }
            message:
              {
                type: string,
                maxLength: 1000,
                description: Plain text (no < or >).,
              }
        - type: object
          additionalProperties: false
          required: [type]
          description: Archive the card.
          properties:
            type: { type: string, enum: [archive] }
        - type: object
          additionalProperties: false
          required: [type]
          description: Archive the card and its conversations.
          properties:
            type: { type: string, enum: [archive_convos] }
        - type: object
          additionalProperties: false
          required: [type, url]
          description: POST the card to a URL. The URL must be a public https address - localhost, private/internal addresses, credentials and non-default ports are rejected.
          properties:
            type: { type: string, enum: [webhook] }
            url: { type: string, maxLength: 2000 }
      discriminator:
        propertyName: type
    CreateAutomationRequest:
      type: object
      additionalProperties: false
      required: [title, trigger, actions]
      properties:
        title: { type: string, maxLength: 200 }
        trigger: { $ref: "#/components/schemas/AutomationTrigger" }
        timing: { $ref: "#/components/schemas/AutomationTiming" }
        actions:
          type: array
          minItems: 1
          maxItems: 10
          items: { $ref: "#/components/schemas/AutomationAction" }
    ValidationError:
      type: object
      description: |
        Returned with HTTP 400 when the request body fails shape validation (wrong type,
        missing required field, malformed email/phone number, value out of bounds).
        `meta.issues` lists every offending field.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum: [VALIDATION_ERROR]
            message:
              type: string
              example: One or more fields are invalid
            meta:
              type: object
              properties:
                issues:
                  type: array
                  items:
                    type: object
                    properties:
                      field:
                        type: string
                        description: Dotted path to the offending property (for example `to.0.value`); empty for a problem with the body as a whole.
                        example: to.0.value
                      code:
                        type: string
                        description: Machine-readable issue code (for example `invalid_type`, `invalid_format`, `too_small`).
                        example: invalid_format
                      message:
                        type: string
                        example: must be a valid email address
        requestId:
          type: string
          description: Id of the request; also sent on every response as the `X-Request-Id` header.

    Error:
      type: object
      properties:
        error:
          type: string
        message:
          type: string

    TaskDateRange:
      type: object
      description: Date-range filter. Either bound may be supplied independently — `startDate` maps to `$gte`, `endDate` to `$lte`.
      properties:
        startDate:
          type: string
          format: date-time
        endDate:
          type: string
          format: date-time

    Task:
      type: object
      properties:
        _id:
          type: string
        companyId:
          type: string
        title:
          type: string
        mergedTitle:
          type: string
          description: Title with `{{ }}` merge tags resolved against the task's representative contact, the company, and the acting user.
        description:
          type: string
        mergedDescription:
          type: string
          description: Description with `{{ }}` merge tags resolved (HTML output).
        userId:
          type: string
          description: Assigned user id.
        checked:
          type: boolean
        archived:
          type: boolean
        status:
          type: string
        priority:
          type: string
        dueAt:
          type: string
          format: date-time
        aptletInstanceId:
          type: string
        aptletUuid:
          type: string
        attachments:
          type: array
          items:
            type: object
            properties:
              _id:
                type: string
              name:
                type: string
              type:
                type: string
        priorityLabel:
          type: string
          description: Present only when includeMetadata=true.
        statusLabel:
          type: string
          description: Present only when includeMetadata=true.
        assignee:
          type: object
          description: Present only when includeMetadata=true.
          properties:
            _id:
              type: string
            fullName:
              type: string
      additionalProperties: true

    CardRule:
      type: object
      description: An older create-card rule (`board` or `schedule`) that moves or copies cards. Not an Automation.
      properties:
        uuid:
          type: string
          description: Unique identifier for the rule.
        title:
          type: string
          description: Display name of the automation.
        automationType:
          type: string
          enum: [board, schedule]
          description: "`board` automations move/copy cards based on board events; `schedule` automations run on a time-based schedule."
        archived:
          type: boolean
        destinationBoardUuid:
          type: string
          description: UUID of the destination board. Defaults to the current board.
        sourceBoardUuid:
          type: string
          description: (board type only) UUID of the source board.
        runMode:
          type: string
          description: "Board type: `continuously`, `onDate`, `onMonth`, `dateRange`, `onDayOfWeek`. Schedule type: `onDayOfWeek`, `onDate`, `onDayOfYear`."
        segment:
          description: (board type only) Card segment/stage filter.
        moveMode:
          type: string
          enum: [Copy, Move]
          description: (board type only) Whether to copy or move matching cards.
        timeOfDay:
          type: number
          description: (schedule type only) Hour of the day to run (0–23).

    BoardField:
      type: object
      properties:
        uuid:
          type: string
          description: Unique field identifier — use this as the key when reading/writing card data.
        name:
          type: string
          description: Display name of the field.
        type:
          type: string
          description: "Field type. Fields created through the API use one of the types in `NewBoardFieldType` (`string`, `text`, `rich-text`, `number`, `money`, `percent`, `multiplier`, `boolean`, `date`, `datetime-local`, `email`, `emails`, `tel`, `phones`, `url`, `address`, `person`, `persons`, `user`, `file`, `files`, `heroImage`, `embedVideo`, `stringList`, `tags`, `singleselect`, `multiselect`, `checkboxlist`). Mirror fields (a copy of another board's field) are added with `POST /api/board/{boardId}/configuration/mirror-fields` and take the mirrored field's type. Boards built in the Aptly app can also contain relational and system types, e.g. `stageselect`, `sourceselect`, `relatedAptlets`, `mirror`, `mirror-contact`, `location`, `unit`, `board`, `calculation`, `ai-computed`, `cardId`, `daysOpened`, `timeSince`, `qualified`, `lostReason`, `lostDetails`, `relatedEvents`, `relatedDocuments`, `contactType`, `teams`."
        position:
          type: integer
          description: Display order (zero-based).
        optional:
          type: boolean
          description: Whether the field is optional on cards.
        startCard:
          type: boolean
          description: Whether the field is shown on the form when a card is created.
        archived:
          type: boolean
          description: Whether the field has been archived (hidden from the UI).

    BoardOptions:
      type: object
      description: Configurable board-level settings. All fields are optional — only include the ones you want to change on PATCH.
      properties:
        conversionFlair:
          type: string
          enum: [confetti, fireworks]
        reassignDiscussions:
          type: string
          enum: ["", prompt, auto]
        minCallDuration:
          type: number
          description: Minimum call duration in seconds before the call is logged.
        boardEmailOnlyParsedCards:
          type: boolean
        displayFieldNames:
          type: boolean
        agingIndicator:
          type: boolean
        applicationFeatures:
          type: boolean
        applicationLinking:
          type: boolean
        defaultContactType:
          type: string
        updateBadgeField:
          type: string
          description: Field UUID to use as the card badge label.

    TemplateUpsert:
      type: object
      required: [name, description, templateType, subject, content]
      properties:
        companyId:
          type: string
          description: Required with a partner token; resolved from an API key or delegate token.
        userId:
          type: string
          description: >-
            The acting user. Required with an API key or partner token; a delegate token
            supplies its own.
        name:
          type: string
        description:
          type: string
        templateType:
          type: string
          enum: [sms, email, form, eSignature, pdf]
          description: >-
            Template kind. `blockDocument` is not accepted — it is an internal type for
            document blocks, not a user-facing template.
        subject:
          type: string
          description: >-
            Email subject, or the file name for a `pdf` template — in which case
            filename-illegal characters are stripped. Merge placeholders work here
            too, and are shape-checked the same way.
        content:
          type: string
          description: >-
            Template body. Merge placeholders take the form `{{value}}` or
            `{{value || fallback: text}}`, where `value` is an item's `value` from
            `GET /api/board/{boardId}/merge-fields` — for example `{{firstname}}`,
            `{{Locations["Rent"] || fallback: TBD}}`. They are checked for shape —
            an unclosed `{{`, an empty field name or a malformed bracket
            reference is rejected. Whether each field exists is not checked: an
            unknown one renders as its fallback at merge time.
        htmlBuilder:
          type: boolean
          enum: [false]
          default: false
          description: >-
            Must be `false` or omitted. HTML-builder templates keep their markup in a
            builder-owned `builderData` format and can only be authored in the app.
        builderData:
          type: object
          description: Builder document, when one already exists.
        folderId:
          type: string
          description: >-
            Library folder the template belongs to — must be an existing, non-archived
            folder in the company. Governs who may edit the template.
        aptletUuid:
          type: string
          description: >-
            Board the template is scoped to. Must be an existing, non-archived board in
            the company.
        signature:
          type: string
        attachmentIds:
          type: array
          items:
            type: string
          description: Uploaded file ids to attach.

    MergeFieldItem:
      type: object
      description: One selectable merge field — the display label and the body of the `{{ ... }}` token it inserts.
      properties:
        label:
          type: string
          description: Display label.
        value:
          type: string
          description: |
            The token body. Global groups use a bare key (`firstname`,
            `customFields.<id>.<NameWithoutSpaces>`, `nowDate`); board items use the
            `Board["Label"]` form (`Locations["Rent"]`, `Locations["Card Details"]`),
            except the unit/location link entries, which key on the raw name
            (`Locations["activeToursLink"]`).

    MergeFieldCatalog:
      type: object
      properties:
        recipient:
          type: array
          items:
            $ref: "#/components/schemas/MergeFieldItem"
        board:
          type: array
          description: The fields and card entries of the board in the path. The hook's per-board `locations`/`aptlets` groups, flattened.
          items:
            $ref: "#/components/schemas/MergeFieldItem"
        sender:
          type: array
          items:
            $ref: "#/components/schemas/MergeFieldItem"
        showing:
          type: array
          items:
            $ref: "#/components/schemas/MergeFieldItem"
        lease:
          type: array
          items:
            $ref: "#/components/schemas/MergeFieldItem"
        application:
          type: array
          items:
            $ref: "#/components/schemas/MergeFieldItem"
        general:
          type: array
          description: Today's date, attachment links, unsubscribe url. The `system` group in the Meteor hook.
          items:
            $ref: "#/components/schemas/MergeFieldItem"
        company:
          type: array
          description: Company name, address, contact details and logo. The `companyFields` group in the Meteor hook.
          items:
            $ref: "#/components/schemas/MergeFieldItem"

    Template:
      type: object
      properties:
        _id:
          type: string
          description: Template ID.
        companyId:
          type: string
          description: Company the template belongs to.
        name:
          type: string
          description: Template display name.
        templateType:
          type: string
          enum: [sms, email, form, eSignature, pdf, blockDocument]
          description: Template category.
        archived:
          type: boolean
        createdAt:
          type: string
          format: date-time
        createdBy:
          type: string
          description: User ID of the creator.
        updatedAt:
          type: string
          format: date-time
      additionalProperties:
        description: "Additional properties depend on the templateType (e.g. `subject`, `content` for email templates)."

    TourFeedback:
      type: object
      description: One tour showing's post-tour feedback survey.
      properties:
        showingId:
          type: string
          description: Showing (portal application) ID.
        status:
          type: string
          enum: [completed, pending]
          description: "`completed` once the applicant answered; `pending` while the survey is still out."
        sentAt:
          type: string
          format: date-time
          nullable: true
          description: When the survey was emailed to the applicant.
        completedAt:
          type: string
          format: date-time
          nullable: true
          description: When the applicant submitted their answers.
        tourDate:
          type: string
          format: date-time
          nullable: true
        instanceId:
          type: string
          nullable: true
          description: The location/unit card the tour was for.
        propertyName:
          type: string
          nullable: true
          description: Name of that location card.
        applicant:
          type: object
          properties:
            firstName:
              type: string
              nullable: true
            lastName:
              type: string
              nullable: true
            email:
              type: string
              nullable: true
        responses:
          type: array
          items:
            type: object
            properties:
              question:
                type: string
                nullable: true
                description: The question text as it was shown to the applicant.
              answer:
                type: string
                nullable: true
                description: The raw answer. Rating questions answer "1"-"5"; text questions answer free text.
              rating:
                type: integer
                nullable: true
                minimum: 1
                maximum: 5
                description: The answer as a number when it is a 1-5 rating, otherwise null.
        averageRating:
          type: number
          nullable: true
          description: Mean of this survey's numeric ratings, or null when nothing was rated.

paths:
  /api/users:
    get:
      summary: List users
      description: |
        Returns all non-archived users for the API key's company.
      operationId: listUsers
      tags: [Users]
      security:
        - ApiKeyHeader: []
      responses:
        "200":
          description: List of users.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        _id:
                          type: string
                          description: User ID.
                        name:
                          type: string
                          description: Full name (firstname + lastname).
                        role:
                          type: string
                          nullable: true
                          description: Role name, or null if the role has been deleted.
              example:
                data:
                  - _id: "abc123"
                    name: "Alice Smith"
                    role: "Admin"
                  - _id: "def456"
                    name: "Bob Jones"
                    role: "Staff"
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/users/{userId}/inboxes:
    get:
      summary: List a user's email inboxes
      description: |
        Returns the email inboxes (Hermes/Nylas channels) accessible to the given user
        within your company. Includes personal inboxes (where the user is a direct member)
        and shared inboxes the user can access via team membership.

        Each inbox is tagged `kind: "personal"` (`isShared` is false and the user is in the
        channel's `userIds`) or `kind: "shared"` (channel marked `isShared`, or accessible
        only through a team membership).

        The user must belong to the company associated with your API key. Returns `401` if
        the user does not belong to your company.
      operationId: listUserInboxes
      tags: [Users]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
          description: The user's ID.
      responses:
        "200":
          description: List of inboxes accessible to the user.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        _id:
                          type: string
                          description: Channel document ID.
                        channelId:
                          type: string
                          nullable: true
                          description: External channel meta ID (`meta.id`).
                        type:
                          type: string
                          description: "Channel transport type: `Hermes` or `Nylas`."
                        provider:
                          type: string
                          nullable: true
                          description: "Upstream provider, e.g. `google`, `office365`."
                        email:
                          type: string
                          nullable: true
                          description: Email address of the inbox.
                        name:
                          type: string
                          nullable: true
                          description: Display name of the inbox.
                        kind:
                          type: string
                          enum: [personal, shared]
                          description: Whether the user has personal or shared access.
              example:
                data:
                  - _id: "ch_abc"
                    channelId: "acc-1"
                    type: "Hermes"
                    provider: "google"
                    email: "alice@example.com"
                    name: "Alice's Inbox"
                    kind: "personal"
                  - _id: "ch_def"
                    channelId: "acc-2"
                    type: "Hermes"
                    provider: "google"
                    email: "leasing@example.com"
                    name: "Leasing Team"
                    kind: "shared"
        "401":
          description: Invalid or missing API key, or user does not belong to your company.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/inboxes:
    get:
      summary: List inboxes you can query
      description: |
        Returns the email and phone/SMS inboxes available to the credential, for use
        as the `channelId` on the messages and analytics endpoints.

        With delegate-token auth (`inboxes:*` scope) results are limited to
        inboxes the authenticated user can reach, and each entry carries `access`:
        `owned` when the user is a member of the inbox (directly or through a team),
        `monitored` when it is another user's private inbox that the caller is
        permitted to monitor. `scope` filters on that value.

        Monitoring requires the `conversations.monitor_admin` permission on the
        caller and `conversations.monitor_access` on the inbox owner's role, so a
        caller without those permissions sees only `owned` inboxes.

        API keys and partner tokens are company-scoped and carry no user identity, so
        they return every inbox in the company, omit `access`, and reject `scope`
        with a `400`.
      operationId: listInboxes
      tags: [Inboxes]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: scope
          in: query
          schema:
            type: string
            enum: [owned, monitored, all]
            default: all
          description: >-
            Filter by access type. Delegate-token auth only — rejected for
            company-scoped credentials.
      responses:
        "200":
          description: Inboxes available to the credential.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        channelId:
                          type: string
                          description: External channel meta ID (`meta.id`). Use this on the other inbox endpoints.
                        type:
                          type: string
                          enum: [email, phone]
                        provider:
                          type: string
                          nullable: true
                          description: Underlying provider, e.g. `gmail`, `office365`, `twilio`.
                        name:
                          type: string
                          nullable: true
                        email:
                          type: string
                          nullable: true
                          description: Set for email inboxes.
                        number:
                          type: string
                          nullable: true
                          description: Set for phone/SMS inboxes.
                        isShared:
                          type: boolean
                        access:
                          type: string
                          enum: [owned, monitored]
                          description: >-
                            Present only with delegate-token auth. `owned` = the user
                            is a member of the inbox; `monitored` = another user's
                            private inbox the caller may monitor.
        "400":
          description: Invalid `scope`, or `scope` used with a company-scoped credential.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Invalid or missing credential.

  /api/inboxes/{channelId}/threads:
    get:
      summary: List threads and messages on an inbox
      description: |
        Returns threads on the inbox, newest activity first, each with its messages
        and stored per-thread metrics. Paging is over **threads**, not individual
        messages.

        The window filters on the thread's last message time and `dateTo` is
        exclusive. Automated/junk threads are excluded unless `includeJunk=true`.

        `sentVia` indicates whether an outbound was composed in Aptly (`aptly`) or
        sent from the user's own mail client and synced in (`native`). Inbound
        messages are always `null`.
      operationId: listInboxThreads
      tags: [Inboxes]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: channelId
          in: path
          required: true
          schema:
            type: string
          description: External channel meta ID (`meta.id`) of the inbox.
        - name: dateFrom
          in: query
          schema:
            type: string
            format: date-time
          description: ISO date. Only threads with activity at or after this time.
        - name: dateTo
          in: query
          schema:
            type: string
            format: date-time
          description: ISO date, exclusive. Range must not exceed 400 days.
        - name: direction
          in: query
          schema:
            type: string
            enum: [in, out]
          description: Filter to threads whose most recent message went this way.
        - name: unreadOnly
          in: query
          schema:
            type: boolean
            default: false
          description: >-
            When true, return only unread threads. Unread is the thread's stored read state, a
            single flag shared by everyone with access to the inbox (a thread any teammate has
            opened counts as read). Applied before paging, so `count` reflects the filtered total.
        - name: includeJunk
          in: query
          schema:
            type: boolean
            default: false
          description: Include threads classified as junk/automated.
        - name: page
          in: query
          schema:
            type: integer
            default: 0
          description: Zero-based page number (0–9999).
        - name: pageSize
          in: query
          schema:
            type: integer
            default: 20
            maximum: 200
          description: Threads per page.
      responses:
        "200":
          description: Threads with their messages and metrics.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/InboxThread"
                  count:
                    type: integer
                    description: Total matching threads across all pages.
                  page:
                    type: integer
                  pageSize:
                    type: integer
        "400":
          description: Invalid date range, paging, direction, or unreadOnly value.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Invalid or missing credential.
        "403":
          description: The authenticated user cannot reach this inbox.
        "404":
          description: Inbox not found in your company.

  /api/inboxes/{channelId}/messages:
    get:
      summary: List threads and messages on an inbox (deprecated)
      description: |
        **Deprecated:** use `GET /api/inboxes/{channelId}/threads`, which returns
        exactly the same response. This path is a kept-alive alias; the new name
        says what it pages over (threads, not messages).

        Returns threads on the inbox, newest activity first, each with its messages
        and stored per-thread metrics. Paging is over **threads**, not individual
        messages.

        The window filters on the thread's last message time and `dateTo` is
        exclusive. Automated/junk threads are excluded unless `includeJunk=true`.

        `sentVia` indicates whether an outbound was composed in Aptly (`aptly`) or
        sent from the user's own mail client and synced in (`native`). Inbound
        messages are always `null`.
      operationId: listInboxMessages
      deprecated: true
      tags: [Inboxes]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: channelId
          in: path
          required: true
          schema:
            type: string
          description: External channel meta ID (`meta.id`) of the inbox.
        - name: dateFrom
          in: query
          schema:
            type: string
            format: date-time
          description: ISO date. Only threads with activity at or after this time.
        - name: dateTo
          in: query
          schema:
            type: string
            format: date-time
          description: ISO date, exclusive. Range must not exceed 400 days.
        - name: direction
          in: query
          schema:
            type: string
            enum: [in, out]
          description: Filter to threads whose most recent message went this way.
        - name: unreadOnly
          in: query
          schema:
            type: boolean
            default: false
          description: >-
            When true, return only unread threads. Unread is the thread's stored read state, a
            single flag shared by everyone with access to the inbox (a thread any teammate has
            opened counts as read). Applied before paging, so `count` reflects the filtered total.
        - name: includeJunk
          in: query
          schema:
            type: boolean
            default: false
          description: Include threads classified as junk/automated.
        - name: page
          in: query
          schema:
            type: integer
            default: 0
          description: Zero-based page number (0–9999).
        - name: pageSize
          in: query
          schema:
            type: integer
            default: 20
            maximum: 200
          description: Threads per page.
      responses:
        "200":
          description: Threads with their messages and metrics.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/InboxThread"
                  count:
                    type: integer
                    description: Total matching threads across all pages.
                  page:
                    type: integer
                  pageSize:
                    type: integer
        "400":
          description: Invalid date range, paging, direction, or unreadOnly value.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Invalid or missing credential.
        "403":
          description: The authenticated user cannot reach this inbox.
        "404":
          description: Inbox not found in your company.

  /api/inboxes/{channelId}/analytics:
    get:
      summary: Conversation analytics for an inbox
      description: |
        Aggregated conversation metrics for one inbox over a date range.

        Response times are **business-hours aware** — they use the company's
        configured business hours and holidays, the same basis as in-app reports, so
        figures reconcile. `responseCount` is the number of replies the averages were
        computed from.

        Averages and rates are `null` rather than `0` when the window contains
        nothing to measure. Automated/junk threads are excluded unless
        `includeJunk=true`.

        `sentiment` carries a numeric average plus the `coverage` it was computed
        from, and `enabled` reports whether scoring is currently switched on for the
        inbox. Scores exist only for threads the pipeline actually scored, so a
        coverage of 40% means the average describes 40% of the window's threads.
        `topics` lists the categories applied to threads in the window with their
        thread counts.

        Qualitative tone summaries (prose descriptions of tone) are not part of this
        endpoint yet and are absent from the response rather than returned as null.
      operationId: getInboxAnalytics
      tags: [Inboxes]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: channelId
          in: path
          required: true
          schema:
            type: string
          description: External channel meta ID (`meta.id`) of the inbox.
        - name: dateFrom
          in: query
          schema:
            type: string
            format: date-time
        - name: dateTo
          in: query
          schema:
            type: string
            format: date-time
          description: Exclusive. Range must not exceed 400 days.
        - name: groupBy
          in: query
          schema:
            type: string
            enum: [assignee, contactType]
          description: >-
            Break the same metrics out per assignee, or per the counterpart's contact
            type. Omit for a single total.
        - name: includeJunk
          in: query
          schema:
            type: boolean
            default: false
      responses:
        "200":
          description: Aggregated metrics for the window.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/InboxAnalytics"
        "400":
          description: Invalid date range or `groupBy` value.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Invalid or missing credential.
        "403":
          description: The authenticated user cannot reach this inbox.
        "404":
          description: Inbox not found in your company.

  /api/inboxes/{channelId}/history:
    get:
      summary: Activity history for an inbox
      description: |
        What happened on the inbox's threads, newest first: status changes,
        assignments, folder moves, topic edits and email sends by users, email opens
        by recipients, and automation runs.

        Without `streamId`, history covers the inbox's 100 most recently active
        threads; pass `streamId` to read one thread (including older ones). With
        `userId`, only entries that user performed are returned, so email opens and
        automation runs (which have no user actor) are left out.
      operationId: getInboxHistory
      tags: [Inboxes]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: channelId
          in: path
          required: true
          schema:
            type: string
          description: External channel meta ID (`meta.id`) of the inbox.
        - name: streamId
          in: query
          schema:
            type: string
          description: Only this thread. Must belong to the inbox.
        - name: userId
          in: query
          schema:
            type: string
          description: Only entries performed by this Aptly user.
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 1000
            default: 1000
          description: Maximum entries returned.
      responses:
        "200":
          description: History entries, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        type:
                          type: string
                          description: >-
                            `threadAssign`, `changeStatus`, `moveToFolder`, `editTopic`,
                            `emailSent` (and other user action types), `openTrackingView`
                            or `automationEvent`.
                        date:
                          type: string
                          format: date-time
                        streamId:
                          type: string
                          description: Thread the entry belongs to.
                        actor:
                          type: object
                          description: >-
                            `{ id, type }` where type is `user`, `person` or `aptly`;
                            an email opener without a contact record has only `name`.
                          properties:
                            id: { type: string }
                            type: { type: string }
                            name: { type: string }
                        assignedTo:
                          type: string
                          description: "`threadAssign`: the assigned user's id."
                        message:
                          type: string
                          description: "`threadAssign`: the note left with the assignment."
                        status:
                          description: "`changeStatus`: the new status."
                        folder:
                          description: "`moveToFolder`: the destination folder."
                        add:
                          type: boolean
                          description: "`editTopic`: true when topics were added, false when removed."
                        issueTypes:
                          type: array
                          items: { type: string }
                          description: "`editTopic`: the topic label ids."
                        subject:
                          type: string
                          description: "`emailSent`: the email subject."
                        automationDetails:
                          type: object
                          description: "`automationEvent` only."
                          properties:
                            automationUuid: { type: string }
                            success: { type: boolean }
                            error: { type: string }
                            description: { type: string }
                            actionPerformed: { type: string }
        "400":
          description: Invalid `limit`, `streamId` or `userId`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Invalid or missing credential.
        "403":
          description: The authenticated user cannot reach this inbox.
        "404":
          description: Inbox not found in your company, or `streamId` is not a thread in this inbox.

  /api/inboxes/{channelId}/threads/{threadId}:
    get:
      summary: Get one thread on an inbox
      description: |
        Returns one thread in the same shape as an entry of
        `GET /api/inboxes/{channelId}/threads`: its messages and stored per-thread
        metrics. Message `body` is the stored snippet; use the message endpoint
        below for the full body and attachments.

        Junk threads are returned too, since the thread is asked for by id. A
        thread that belongs to another inbox returns 404.
      operationId: getInboxThread
      tags: [Inboxes]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: channelId
          in: path
          required: true
          schema:
            type: string
          description: External channel meta ID (`meta.id`) of the inbox.
        - name: threadId
          in: path
          required: true
          schema:
            type: string
          description: Thread id from the threads list.
      responses:
        "200":
          description: The thread.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/InboxThread"
        "403":
          description: No access to this inbox.
        "404":
          description: Inbox or thread not found.
  /api/inboxes/{channelId}/threads/{threadId}/assign:
    post:
      summary: Assign or unassign a thread
      description: |
        Assigns the thread to a user, or unassigns it when `assigneeId` is `null`.
        Runs the same assignment the Aptly apps do: the acting user must be able
        to see the thread, the assignee must be in the company (and in the
        thread's channel when that channel is shared), the thread's history
        records the assignment, and the assignee is notified.

        The acting user is the delegate token's own user. With an API key or
        partner token, name one in `userId`.

        If the acting user would lose access to the thread, or it would leave
        their Mine view, the request answers 409 with code `STREAM_LOSE_ACCESS`
        or `remove_stream_from_mine_view`. Repeat it with `force: true` to
        proceed.
      operationId: assignInboxThread
      tags: [Inboxes]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: channelId
          in: path
          required: true
          schema:
            type: string
          description: External channel meta ID (`meta.id`) of the inbox.
        - name: threadId
          in: path
          required: true
          schema:
            type: string
          description: Thread id from the threads list.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [assigneeId]
              properties:
                assigneeId:
                  type: string
                  nullable: true
                  description: User to assign the thread to, or `null` to unassign it.
                userId:
                  type: string
                  description: Acting user. Required with an API key or partner token.
                force:
                  type: boolean
                  description: Proceed past the two 409 access warnings.
      responses:
        "200":
          description: Assigned.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      threadId:
                        type: string
                      assigneeId:
                        type: string
                        nullable: true
        "400":
          description: Invalid body, missing `userId`, assignee not in the company or not in the thread's channel.
        "401":
          description: The acting user is not in the company.
        "403":
          description: No access to this inbox, or the acting user is a guest.
        "404":
          description: Inbox or thread not found, or the acting user cannot see the thread.
        "409":
          description: Access warning (`STREAM_LOSE_ACCESS` or `remove_stream_from_mine_view`); repeat with `force` to proceed.
  /api/inboxes/{channelId}/threads/{threadId}/relate-card:
    post:
      summary: Relate a card to a thread
      description: |
        Relates a card to the thread, so the thread appears in the card's related
        conversations. The card is not created under the thread; it is only
        related to it. Undo with `POST .../unrelate-card`. Runs the same
        relation the Aptly apps do: the acting user must be able to see the
        thread and have read access to the card's board, and the card's
        conversation metrics are recalculated. Relating an already-related card
        is a no-op.

        The acting user is the delegate token's own user. With an API key or
        partner token, name one in `userId`.
      operationId: relateInboxThreadCard
      tags: [Inboxes]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: channelId
          in: path
          required: true
          schema:
            type: string
          description: External channel meta ID (`meta.id`) of the inbox.
        - name: threadId
          in: path
          required: true
          schema:
            type: string
          description: Thread id from the threads list.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [cardId]
              properties:
                cardId:
                  type: string
                  description: The card to relate.
                userId:
                  type: string
                  description: Acting user. Required with an API key or partner token.
      responses:
        "200":
          description: Related.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      threadId:
                        type: string
                      cardId:
                        type: string
                      related:
                        type: boolean
                        description: "`true`."
        "400":
          description: Invalid body or missing `userId`.
        "401":
          description: The acting user is not in the company.
        "403":
          description: No access to this inbox or to the card's board, or the acting user is a guest.
        "404":
          description: Inbox, thread or card not found, or the acting user cannot see the thread.
  /api/inboxes/{channelId}/threads/{threadId}/unrelate-card:
    post:
      summary: Unrelate a card from a thread
      description: |
        Removes the card's relation to the thread, the reverse of
        `POST .../relate-card`. Same checks, and the card's conversation metrics
        are recalculated. Unrelating a card that isn't related is a no-op.

        The acting user is the delegate token's own user. With an API key or
        partner token, name one in `userId`.
      operationId: unrelateInboxThreadCard
      tags: [Inboxes]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: channelId
          in: path
          required: true
          schema:
            type: string
          description: External channel meta ID (`meta.id`) of the inbox.
        - name: threadId
          in: path
          required: true
          schema:
            type: string
          description: Thread id from the threads list.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [cardId]
              properties:
                cardId:
                  type: string
                  description: The card to unrelate.
                userId:
                  type: string
                  description: Acting user. Required with an API key or partner token.
      responses:
        "200":
          description: Unrelated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      threadId:
                        type: string
                      cardId:
                        type: string
                      related:
                        type: boolean
                        description: "`false`."
        "400":
          description: Invalid body or missing `userId`.
        "401":
          description: The acting user is not in the company.
        "403":
          description: No access to this inbox or to the card's board, or the acting user is a guest.
        "404":
          description: Inbox, thread or card not found, or the acting user cannot see the thread.
  /api/inboxes/{channelId}/threads/{threadId}/comments:
    post:
      summary: Add an internal comment to a thread
      description: |
        Adds an internal (team-only) comment to the thread, as the acting user,
        the same as commenting in the app. It is never sent to the contact.
        `content` is HTML and is sanitized. Mention markup in it labels the
        thread for the mentioned users and teams, marks it unread for them and
        notifies them. Mentioned contacts and location cards are related to the
        thread.

        The acting user is the delegate token's own user. With an API key or
        partner token, name one in `userId`. Guests cannot comment.
      operationId: addInboxThreadComment
      tags: [Inboxes]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: channelId
          in: path
          required: true
          schema:
            type: string
          description: External channel meta ID (`meta.id`) of the inbox.
        - name: threadId
          in: path
          required: true
          schema:
            type: string
          description: Thread id from the threads list.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content]
              properties:
                content:
                  type: string
                  minLength: 1
                  maxLength: 50000
                  description: Comment body, HTML.
                userId:
                  type: string
                  description: Acting user (comment author). Required with an API key or partner token.
      responses:
        "201":
          description: Comment added.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      threadId:
                        type: string
                      commentId:
                        type: string
        "400":
          description: Invalid body or missing `userId`.
        "401":
          description: The acting user is not in the company.
        "403":
          description: No access to this inbox, or the acting user is a guest.
        "404":
          description: Inbox or thread not found, or the acting user cannot see the thread.
  /api/inboxes/{channelId}/threads/{threadId}/messages/{messageId}:
    get:
      summary: Get one email message with its full body and attachments
      description: |
        Returns one email message with its **full** body, fetched live from the
        mail provider (the `body` on the list endpoint is only the stored snippet),
        plus metadata for each attachment. Download an attachment's bytes with the
        attachment endpoint below.

        `threadId` and `messageId` are the `threadId` / `messageId` values returned
        by `GET /api/inboxes/{channelId}/threads`. The thread must belong to the
        inbox and the message to the thread. Only email messages are supported;
        other message types return 400.

        Inline images in an HTML body reference attachments as `cid:<contentId>`.
      operationId: getInboxMessage
      tags: [Inboxes]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: channelId
          in: path
          required: true
          schema:
            type: string
          description: External channel meta ID (`meta.id`) of the inbox.
        - name: threadId
          in: path
          required: true
          schema:
            type: string
          description: Thread id from the messages list.
        - name: messageId
          in: path
          required: true
          schema:
            type: string
          description: Message id from the messages list.
      responses:
        "200":
          description: The message.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: "#/components/schemas/InboxMessage"
                      - type: object
                        properties:
                          body:
                            type: string
                            description: Full message body from the provider.
                          bodyType:
                            type: string
                            enum: [html, text]
                          cc:
                            type: array
                            items:
                              type: object
                          attachments:
                            type: array
                            items:
                              $ref: "#/components/schemas/InboxMessageAttachment"
        "400":
          description: Not an email message.
        "403":
          description: No access to this inbox.
        "404":
          description: Inbox, thread or message not found (including a message the provider no longer has).
        "502":
          description: The mail provider request failed.
  /api/inboxes/{channelId}/threads/{threadId}/messages/{messageId}/attachments/{attachmentId}:
    get:
      summary: Download an email attachment
      description: |
        Streams one attachment's bytes through Aptly from the mail provider, with
        `Content-Disposition: attachment` and the attachment's file name.
        `Content-Type` is the attachment's `contentType` from the message endpoint
        (falling back to the provider's, then `application/octet-stream`).
        `attachmentId` is an `attachments[].id` from the message endpoint. A `Range`
        request header is forwarded, so large files can be fetched in parts (206).
      operationId: getInboxMessageAttachment
      tags: [Inboxes]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: channelId
          in: path
          required: true
          schema:
            type: string
          description: External channel meta ID (`meta.id`) of the inbox.
        - name: threadId
          in: path
          required: true
          schema:
            type: string
        - name: messageId
          in: path
          required: true
          schema:
            type: string
        - name: attachmentId
          in: path
          required: true
          schema:
            type: string
        - name: Range
          in: header
          schema:
            type: string
          description: Optional byte range, e.g. `bytes=0-1048575`.
      responses:
        "200":
          description: The attachment bytes.
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        "206":
          description: Partial content for a `Range` request.
        "400":
          description: Not an email message.
        "403":
          description: No access to this inbox.
        "404":
          description: Inbox, thread, message or attachment not found.
        "502":
          description: The mail provider request failed.
  /api/inboxes/{channelId}/trends:
    get:
      summary: Volume and response-time trends for an inbox
      description: |
        A gap-free time series for one inbox, rolled up server-side by day, week,
        month or quarter. Both `dateFrom` and `dateTo` are required (max 400 days);
        `dateTo` is exclusive. Periods with no activity are returned with zero volume
        rather than omitted, so the series can be charted directly.

        All bucketing is UTC. Weeks start Monday (ISO) and are keyed by that Monday's
        date, so week keys sort alongside day keys.

        **Two bucketing bases, deliberately:** `volume` counts messages sent or
        received *during* the period, while `threadCount` and the response/rate
        figures describe threads whose *last activity* fell in the period. Response
        times carry no per-message timestamps, so they can't be attributed to a
        single day of a long-running thread. Do not divide `volume` by `threadCount`.

        Averages are combined from underlying sums and counts, so a quiet day never
        carries the same weight as a busy one. `responseCount` is the number of
        replies behind `avgResponseHrs`.
      operationId: getInboxTrends
      tags: [Inboxes]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: channelId
          in: path
          required: true
          schema:
            type: string
          description: External channel meta ID (`meta.id`) of the inbox.
        - name: dateFrom
          in: query
          required: true
          schema:
            type: string
            format: date-time
        - name: dateTo
          in: query
          required: true
          schema:
            type: string
            format: date-time
          description: Exclusive. Range must not exceed 400 days.
        - name: granularity
          in: query
          schema:
            type: string
            enum: [day, week, month, quarter]
            default: day
        - name: includeJunk
          in: query
          schema:
            type: boolean
            default: false
      responses:
        "200":
          description: Gap-free trend series, oldest period first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      granularity:
                        type: string
                        enum: [day, week, month, quarter]
                      window:
                        type: object
                        properties:
                          from:
                            type: string
                            format: date-time
                          to:
                            type: string
                            format: date-time
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/InboxTrendPeriod"
        "400":
          description: Missing/invalid window, or invalid granularity.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Invalid or missing credential.
        "403":
          description: The authenticated user cannot reach this inbox.
        "404":
          description: Inbox not found in your company.

  /api/inboxes/{channelId}/health:
    get:
      summary: Volume health for an inbox against its own baseline
      description: |
        Compares the requested window's message volume against this inbox's own
        recent norm and scores the deviation, for health/risk surfaces.

        The baseline is a trailing average of the `baselinePeriods` windows of the
        same length immediately before `dateFrom` (default 3). No seasonal
        adjustment is applied. `dateFrom` and `dateTo` are both required; the window
        plus its baseline must stay within a 400-day scan, so long ranges need a
        lower `baselinePeriods`.

        `severityScore` (0–100) tracks the size of a **decline** — growth scores 0
        with label `none`. Bands: `critical` 75+, `high` 50–74, `moderate` 25–49,
        `low` 1–24.

        `trend` compares the two halves of the requested window, which is what
        separates "down and still falling" from "down but recovering".

        **Insufficient history is reported explicitly.** With no baseline volume,
        `volumeVsBaselinePct` and `severityScore` are `null` and `severityLabel` is
        `insufficient_history` — a silent `-100%` would be indistinguishable from a
        real collapse and would drive alerts the data can't justify.
      operationId: getInboxHealth
      tags: [Inboxes]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: channelId
          in: path
          required: true
          schema:
            type: string
          description: External channel meta ID (`meta.id`) of the inbox.
        - name: dateFrom
          in: query
          required: true
          schema:
            type: string
            format: date-time
        - name: dateTo
          in: query
          required: true
          schema:
            type: string
            format: date-time
          description: Exclusive.
        - name: baselinePeriods
          in: query
          schema:
            type: integer
            default: 3
            minimum: 1
            maximum: 12
          description: >-
            How many same-length windows before `dateFrom` are averaged into the
            baseline.
        - name: includeJunk
          in: query
          schema:
            type: boolean
            default: false
      responses:
        "200":
          description: Volume health for the window.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/InboxHealth"
        "400":
          description: >-
            Missing window, invalid `baselinePeriods`, or a window plus baseline
            exceeding the 400-day scan limit.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Invalid or missing credential.
        "403":
          description: The authenticated user cannot reach this inbox.
        "404":
          description: Inbox not found in your company.

  /api/inboxes/{channelId}/drafts:
    get:
      summary: List unsent drafts on an inbox
      description: |
        Returns the unsent drafts on the given email inbox. The inbox is identified
        by its `channelId` — the same value returned as `channelId` from
        `GET /api/users/{userId}/inboxes`.

        Each item in the response is a draft entry with the parent discussion's
        `streamId` attached. Drafts currently in the process of sending (after
        `POST /api/email/send` was called and before the upstream provider accepts
        the message) are excluded.

        With API-key auth the only scope check is that the inbox belongs to your
        company. With delegate-token auth (`inboxes:*` scope) the inbox must also be
        one the authenticated user can reach personally or via a team membership.
      operationId: listInboxDrafts
      tags: [Inboxes]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: channelId
          in: path
          required: true
          schema:
            type: string
          description: External channel meta ID (`meta.id`) of the inbox.
      responses:
        "200":
          description: List of unsent drafts on the inbox.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        streamId:
                          type: string
                          description: ID of the discussion the draft belongs to.
                        uuid:
                          type: string
                          description: Draft entry UUID — pass as `uuid` to `POST /api/email/send`.
                        channelId:
                          type: string
                          description: External channel meta ID this draft will be sent from.
                        subject:
                          type: string
                          nullable: true
                        content:
                          type: string
                          nullable: true
                          description: HTML body of the draft (Email type).
                        to:
                          type: array
                          items:
                            type: object
                            properties:
                              value:
                                type: string
                              label:
                                type: string
                                nullable: true
                        cc:
                          type: array
                          items:
                            type: object
                        bcc:
                          type: array
                          items:
                            type: object
                        attachments:
                          type: array
                          items:
                            type: object
                        ownerId:
                          type: string
                          nullable: true
                        sentBy:
                          type: string
                          nullable: true
                        addedToOutboxAt:
                          type: string
                          format: date-time
                          nullable: true
              example:
                data:
                  - streamId: "stm_abc"
                    uuid: "drf_123"
                    channelId: "acc-1"
                    subject: "Welcome"
                    content: "<p>Hi there</p>"
                    to:
                      - value: "tenant@example.com"
                    cc: []
                    bcc: []
                    attachments: []
                    ownerId: "u_42"
                    sentBy: "u_42"
                    addedToOutboxAt: "2026-05-25T18:14:00.000Z"
        "401":
          description: Invalid or missing API key, or inbox not accessible.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Inbox not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/schema/{boardId}:
    get:
      summary: Get board schema
      description: |
        Returns the list of fields defined on the board. Always fetch the schema first so
        you know which field keys to use when reading or writing card data.
      operationId: getSchema
      tags: [Schema]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
          description: The board's UUID (`aptlet.uuid`).
      responses:
        "200":
          description: Array of field definitions.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/SchemaField"
              example:
                - key: "abc123"
                  label: "Tenant Name"
                  type: "text"
                - key: "def456"
                  label: "Move-in Date"
                  type: "date"
                - key: "ghi789"
                  label: "Monthly Rent"
                  type: "money"
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: API access is disabled for this board.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Board not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/boards:
    get:
      summary: List boards
      description: |
        Returns all boards in your company that have API access enabled.
        Each board includes its UUID, display name, and a list of pre-built endpoint URLs
        you can use to interact with cards on that board.

        Accepts an API key (`x-token`) or a delegate token (`Authorization: DelegateToken <token>`).
      operationId: listBoards
      tags: [Boards]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      responses:
        "200":
          description: List of API-enabled boards.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Board"
              example:
                data:
                  - uuid: "abc-123-board"
                    name: "Leases"
                    endpoints:
                      - method: "GET"
                        url: "https://core-api.getaptly.com/api/board/abc-123-board"
                        description: "Deprecated: use POST /api/board/{boardId}/list"
                      - method: "POST"
                        url: "https://core-api.getaptly.com/api/board/abc-123-board"
                        description: "Create or update a card"
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    post:
      summary: Create a board
      description: |
        Creates a new board. Requires the key's dedicated "Create Boards" access toggle to be
        enabled, in addition to `insert` permission — unlike the API key's other access
        toggles, this one defaults off and must be explicitly turned on in Setup → Developer.

        A `stage` field is always added automatically (a board can't render a Kanban/pipeline
        view without one) — `ticket` boards default to To Do / In Progress / Done, `deal`
        boards to the standard Aptly deal pipeline. To use your own pipeline instead, pass
        `stages`. You can add, reorder and recolor stages later with `PUT .../configuration/fields/stage`. `stage`, along with a handful of other
        reserved keys (`name`, `dueAt`, `relatedContacts`, and every card-document system key
        such as `_id`, `companyId`, `archived`), can't be used as a custom field's `uuid`.

        Only a safe, self-contained subset of Aptly's field types may be used for custom
        fields — relational types that reference other boards (e.g. `relatedAptlets`,
        `mirror`) and system/computed types the backend manages itself (e.g. `stageselect`,
        `calculation`) are rejected, since a freshly created board has nothing valid for them
        to point at yet.

        The key that creates a board keeps access to it. Even if the key is limited to specific
        boards (so the new board is not on its list), it can read and write the board's cards, see
        it in `GET /api/boards`, and - with the `update` permission but without the "Edit Boards"
        toggle - add, change and remove its fields, change its theme and archive it. This applies
        only to boards created through this endpoint by that key, and ends when the key is deleted.
        It does not extend to other boards, to creating Automations (which has its own toggle), or to
        Tasks, Files and mirror-field links to other boards, which still follow the key's board list.
        Boards created before this change are not affected.
      operationId: createBoard
      tags: [Boards]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  description: Board display name.
                boardType:
                  type: string
                  enum: [ticket, deal]
                  default: ticket
                  description: Determines the default `stage` pipeline. Defaults to `ticket`.
                stages:
                  type: array
                  minItems: 1
                  maxItems: 30
                  description: |
                    The board's stage pipeline, in order. Replaces the default pipeline for
                    `boardType`. The first stage is where new cards start, so it cannot be
                    closed or won. Labels must be unique. Stage ids are generated for you.
                  items:
                    type: object
                    required: [label]
                    properties:
                      label:
                        type: string
                        maxLength: 100
                        description: Stage display name. Unique on the board.
                      color:
                        type: string
                        example: "#1ec968"
                        description: Hex color. Defaults to a palette color by position.
                      closed:
                        type: boolean
                        default: false
                        description: A closed stage ends a card's life, such as Done or Lost.
                      won:
                        type: boolean
                        default: false
                        description: A closed stage that counts as won. Deal boards only. Implies `closed`.
                fields:
                  type: array
                  description: Optional custom fields to add, beyond the automatic `stage` field.
                  items:
                    type: object
                    required: [name, type]
                    properties:
                      name:
                        type: string
                        description: Field display name.
                      type:
                        $ref: "#/components/schemas/NewBoardFieldType"
                      uuid:
                        type: string
                        description: >
                          Optional explicit field key, used later as the key for this field's
                          value in card create/update calls. Must match `^[a-zA-Z][a-zA-Z0-9_]{0,63}$`.
                          Auto-generated when omitted.
                      optional:
                        type: boolean
                        default: true
                      pinned:
                        type: boolean
                        default: false
                        description: Pin this field to the card's Kanban view.
                      startCard:
                        type: boolean
                        description: Show this field on the form when a card is created. Off when omitted.
                      defaultValue:
                        type: string
                      data:
                        type: array
                        description: >
                          Required (non-empty) for `singleselect` and `multiselect` — the
                          field's option list. Not used by the other types.
                        items:
                          type: object
                          required: [label]
                          properties:
                            label:
                              type: string
                            uuid:
                              type: string
                              description: Auto-generated when omitted.
            example:
              name: "Maintenance Requests"
              boardType: "ticket"
              stages:
                - label: "Backlog"
                - label: "In Progress"
                  color: "#f1b44c"
                - label: "Done"
                  closed: true
              fields:
                - name: "Priority"
                  type: "singleselect"
                  data:
                    - label: "Low"
                    - label: "Medium"
                    - label: "High"
      responses:
        "201":
          description: Board created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/Board"
        "400":
          description: >
            `VALIDATION_ERROR` - missing/invalid `name`, `boardType`, or a `fields` entry
            (per-field `meta.issues`, e.g. `fields.0.type`), or a reserved/duplicate field `uuid`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ValidationError"
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: >
            This API key does not have insert permission, or does not have the dedicated
            "Create Boards" access enabled for this key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/board/{boardId}/list:
    post:
      summary: List cards
      description: >-
        Returns a paginated list of cards on the board.


        Field values are keyed by field UUID — use the schema endpoint to map keys to labels.

        Money fields are returned as `{ amount, currency }` where `amount` is a decimal.


        All filter params are optional and ANDed together.


        Supply segmentId OR rules (a JSON array), never both. Rules use the existing saved-segment format.
        Invalid rules fail with 400; missing, private-to-another-user, archived, or wrong-board segments return
        404.


        Auth: API key, delegate token (boards:* or boards:{boardId} read scope), or partner bearer token with
        board-admin. Mobile tokens are not accepted. Keys/delegates require API-enabled boards; partner access
        retains its existing API-enabled bypass. API keys/partners may supply userId for an active company
        member; delegates can only use their token user. Without userId only public segments are visible. Public
        means company-shared.


        This /list POST performs no writes and does not change POST /api/board/{boardId}, which creates/updates
        cards. Rules are ANDed. Relative dates are evaluated on each request. See the Board segments guide for
        values, field comparisons, business-hours rules, and examples.
      operationId: listBoardCards
      tags:
        - Cards
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
          description: The board's UUID.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BoardCardListRequest"
            examples:
              segment:
                summary: Saved segment
                value:
                  page: 0
                  pageSize: 20
                  segmentId: SEGMENT_ID
                  userId: USER_ID
              adhoc:
                summary: My cards in a stage updated within 24 hours
                value:
                  page: 0
                  userId: USER_ID
                  rules:
                    - fieldUuid: assignee
                      operator: anyIn
                      valueSource: currentUser
                    - fieldUuid: stage
                      operator: anyIn
                      value:
                        - In Progress
                    - fieldUuid: updatedAt
                      operator: lastCustomDays
                      value: 1
      responses:
        "200":
          description: Paginated list of cards.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Card"
                  count:
                    type: integer
                    description: Total number of matching cards.
                  page:
                    type: integer
                  pageSize:
                    type: integer
        "400":
          description: Invalid rules, query parameters, or missing user context.
        "401":
          description: Invalid or missing API key.
        "403":
          description: Read access denied, API disabled, company mismatch, or invalid user selection.
        "404":
          description: Board or accessible active segment not found.

  /api/board/{boardId}:
    delete:
      summary: Archive a board
      description: |
        Archives the board, as the Archive action in the Aptly app does: the board, all of its cards,
        its saved filters and its communication templates are archived, and the cards' open tasks are
        closed out. Nothing is hard-deleted, but there is no API to un-archive, and an archived board
        stops resolving through the API (404). Requires `update` permission and the key's "Edit
        Boards" access.
      operationId: archiveBoard
      tags: [Board]
      security:
        - ApiKeyHeader: []
        - PartnerBearer: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Board archived.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      uuid:
                        type: string
                      archived:
                        type: boolean
                      cardsArchived:
                        type: integer
                        description: Cards archived by this call (cards that were already archived aren't counted).
        "401":
          description: Invalid or missing API key.
        "403":
          description: The key lacks `update` permission or "Edit Boards" access, or the board isn't API-enabled.
        "404":
          description: Board not found (or already archived).
    get:
      summary: List cards (deprecated)
      description: >
        Deprecated: use POST /api/board/{boardId}/list. Both endpoints use the same read-only listing
        implementation and response. GET remains supported.


        Returns a paginated list of cards on the board.


        Field values are keyed by field UUID — use the schema endpoint to map keys to labels.

        Money fields are returned as `{ amount, currency }` where `amount` is a decimal.


        All filter params are optional and ANDed together.


        Supply segmentId OR rules (a JSON-encoded array), never both. Rules use the existing saved-segment
        format. Invalid rules fail with 400; missing, private-to-another-user, archived, or wrong-board segments
        return 404.


        Auth: API key, delegate token (boards:* or boards:{boardId} read scope), or partner bearer token with
        board-admin. Mobile tokens are not accepted. Keys/delegates require API-enabled boards; partner access
        retains its existing API-enabled bypass. API keys/partners may supply userId for an active company
        member; delegates can only use their token user. Without userId only public segments are visible. Public
        means company-shared.
      operationId: listCards
      tags:
        - Cards
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
          description: The board's UUID.
        - name: page
          in: query
          required: true
          schema:
            type: integer
            minimum: 0
            maximum: 9999
          description: Zero-based page number. Legacy GET parses the base-10 integer prefix (1.5 or 1e2 becomes 1).
        - name: pageSize
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 1000
            default: 20
          description: Number of cards per page. Legacy GET parses the base-10 integer prefix; omitted or empty defaults to 20.
        - name: updatedAtMin
          in: query
          schema:
            type: string
            format: date-time
          description: Only return cards updated after this ISO timestamp.
        - name: includeArchived
          in: query
          schema:
            type: boolean
            default: false
          description: Include archived cards. Legacy GET treats empty or the exact string false as false; any other nonempty string (including 1 or 0) as true. Omitted lets archive rules control selection.
        - name: relatedId
          in: query
          schema:
            type: string
          description: Only return cards where `references.value` contains this ID.
        - name: contactEmail
          in: query
          schema:
            type: string
          description: Resolves the email to contact IDs, then filters cards referencing those contacts.
        - name: keyTerm
          in: query
          schema:
            type: string
          description: Full-text autocomplete search on card title using the Atlas Search index.
        - name: assignee
          in: query
          schema:
            type: string
          description: Filter cards by assignee user ID.
        - name: segmentId
          schema:
            type: string
            maxLength: 256
          description: Existing accessible, active segment on this board; mutually exclusive with rules.
          in: query
        - name: rules
          schema:
            type: string
            maxLength: 64000
          description: JSON-encoded BoardFilterRule array, at most 100 rules. Prefer POST /list with a native JSON array.
          in: query
        - name: userId
          schema:
            type: string
            maxLength: 256
          description:
            Active company member whose private segments/currentUser values to use. API keys/partners may
            select a member; delegates cannot select another user.
          in: query
      responses:
        "200":
          description: Paginated list of cards.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Card"
                  count:
                    type: integer
                    description: Total number of matching cards.
                  page:
                    type: integer
                  pageSize:
                    type: integer
        "400":
          description: Invalid rules, query parameters, or missing user context.
        "401":
          description: Invalid or missing API key.
        "403":
          description: Read access denied, API disabled, company mismatch, or invalid user selection.
        "404":
          description: Board or accessible active segment not found.
      deprecated: true
    post:
      summary: Create or update a card
      description: |
        Creates a new card on the board. Use field UUIDs (from the schema endpoint) as keys in the request body.

        To update an existing card, include its `_id` in the request body. The card must belong to this board.
        Fields not provided in the body are left unchanged on update.

        Contact fields take person ids from the Contacts API: a `person` field takes
        `{ "_id": "<personId>" }` (or `null` to clear it), and a `persons` field takes an
        array of them (`[]` clears it). Each id must be an existing contact in the board's
        company. The stored value is expanded to `{ _id, name, duogram }`, where `name` is
        the contact's full name and `duogram` its initials; any other keys sent are ignored.

        Card-link fields (`location`, `unit`, `relatedAptlets`) always take an array of
        `{ "_id": "<cardId>" }` (`[]` clears the field). Each id must be a live
        (non-archived) card in the board's company that matches every part of the
        field's `filter` that is set: the card must be on one of the boards in
        `filter.aptletUuids`, and its board's type must be in `filter.aptletTypes`.
        `location` and `unit` fields with no `aptletUuids` take cards from the built-in
        `location` and `unit` boards. Values are stored as `{ _id, name, duogram }`.
      operationId: createCard
      tags:
        - Cards
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                _id:
                  type: string
                  description: Existing card ID. When provided, updates that card instead of creating a new one.
                name:
                  type: string
                  description: Card title (also accepted as `title`).
              additionalProperties:
                description: Any board field UUID as key, with the field's value.
            example:
              name: John Smith
              abc123: john@example.com
              ghi789: 1500
              jkl012:
                _id: aBcD1234eFgH5678i
              mno345:
                - _id: kLmN9012oPqR3456s
      responses:
        "200":
          description: Card created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      _id:
                        type: string
                        description: ID of the created card.
        "400":
          description: A `person`/`persons` or `location`/`unit`/`relatedAptlets` value has the wrong shape, or names a contact or card that does not exist in the board's company or that the field's filter does not allow.
        "401":
          description: Invalid or missing API key.
        "403":
          description: API access is disabled for this board.
        "404":
          description: Board not found.

  /api/board/{boardId}/merge-fields:
    get:
      summary: List merge fields for a board
      description: |
        Returns the merge-field catalog the template editors offer for this board, grouped
        as the Meteor `useMergeFields` hook groups it: `recipient`, `board`, `sender`,
        `showing`, `lease`, `application`, `general`, `company` (the hook's `system` and
        `companyFields` groups are named `general` and `company` here, and its per-board
        `locations`/`aptlets` groups are one flat `board` list, since the path already
        names the board).

        `recipient` is the contact field list plus the company's Person custom fields;
        `board` is the board's mergeable fields plus the image-embed, card-detail,
        contact-table and card-link entries.
      operationId: listBoardMergeFields
      tags: [Board]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
          description: The board's UUID.
      responses:
        "200":
          description: The merge-field catalog for this board.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/MergeFieldCatalog"
        "401":
          description: Invalid or missing credential.
        "404":
          description: Board not found.

  /api/board/{boardId}/users:
    get:
      summary: List users who can open a board
      description: |
        The company's users who can open this board, for assignee pickers. Access comes
        from a direct share, a share to one of the user's teams, or — for non-guest users —
        the board not being private. `canEdit` is true when that access includes editing.
        Unlike `configuration/shares`, this resolves teams and public access into users and
        accepts a delegate token with a boards read scope.
      operationId: listBoardUsers
      tags: [Board]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
          description: The board's UUID.
      responses:
        "200":
          description: Users with access to the board.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        _id:
                          type: string
                        name:
                          type: string
                        role:
                          type: string
                          nullable: true
                        canEdit:
                          type: boolean
        "401":
          description: Invalid or missing credential.
        "403":
          description: The credential cannot read this board.
        "404":
          description: Board not found.

  /api/board/{boardId}/activity:
    get:
      summary: Get a board's activity feed
      description: |
        One merged, newest-first feed for this board: card and board-configuration changes,
        automation log rows (runs and configuration edits) and card comments. Every read is
        pinned to this board and bounded in time (the last 92 days, or 365 when `cardId` is given).
        Page with `before`: pass the `nextCursor` of the previous page; it is `null` on the last page.
        Change values are shortened (long text is clipped, large structures summarised as
        `[N items]`), and automation configuration snapshots are not included.
        Webhook URL credentials are masked in both current and previous change values.
        Audit rows containing both comments and other changes match both type filters. When
        both are requested, each mixed row appears once as a change item with comments included.
        Unrelated query parameters (including `x-token` authentication and cache-busters)
        are ignored when validating the activity filters.
        Needs a credential that can administer the board's configuration (an API key, or an
        internal partner token with `board-admin`); delegate tokens are refused even when
        an `x-token` is also supplied in a header or query parameter.
      operationId: getBoardActivity
      tags: [Board]
      security:
        - ApiKeyHeader: []
        - PartnerBearer: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
          description: The board's UUID.
        - name: types
          in: query
          required: false
          schema:
            type: string
            example: changes,comments
          description: Comma-separated kinds to include, from `changes`, `automations`, `comments`. Default all three.
        - name: cardId
          in: query
          required: false
          schema:
            type: string
          description: Only this card's activity. Board-configuration changes are left out.
        - name: before
          in: query
          required: false
          schema:
            type: string
          description: The `nextCursor` of the previous page.
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
      responses:
        "200":
          description: A page of activity.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      boardUuid:
                        type: string
                      types:
                        type: array
                        items:
                          type: string
                          enum: [changes, automations, comments]
                      since:
                        type: string
                        format: date-time
                        description: Start of the window the feed covers.
                      nextCursor:
                        type: string
                        nullable: true
                      items:
                        type: array
                        items:
                          type: object
                          required: [id, type, createdAt, actor]
                          properties:
                            id:
                              type: string
                            type:
                              type: string
                              enum: [change, automation, comment]
                            createdAt:
                              type: string
                              format: date-time
                            actor:
                              type: object
                              properties:
                                userId:
                                  type: string
                                  nullable: true
                                name:
                                  type: string
                                  nullable: true
                            cardId:
                              type: string
                            cardName:
                              type: string
                            scope:
                              type: string
                              enum: [card, board]
                              description: change items only.
                            action:
                              type: string
                              description: "change items only: created, edited, archived, reopened or deleted."
                            changes:
                              type: array
                              description: change items only.
                              items:
                                type: object
                                properties:
                                  field:
                                    type: string
                                  fieldName:
                                    type: string
                                  fieldType:
                                    type: string
                                  display:
                                    type: string
                                  value: {}
                                  previous: {}
                            comments:
                              type: array
                              description: Present on comment items and mixed change items when comments are requested. `removed` marks a deleted comment.
                              items:
                                type: object
                                properties:
                                  id:
                                    type: string
                                  text:
                                    type: string
                                  author:
                                    type: string
                                  removed:
                                    type: boolean
                            kind:
                              type: string
                              enum: [run, configuration]
                              description: automation items only.
                            automationType:
                              type: string
                            automationUuid:
                              type: string
                            automationName:
                              type: string
                            description:
                              type: string
                            success:
                              type: boolean
                            error:
                              type: string
        "400":
          description: Invalid query (unknown `types` value, bad `limit`, malformed `before`).
        "401":
          description: Invalid or missing credential.
        "403":
          description: The credential cannot administer this board.
        "404":
          description: Board not found.

  /api/board/{boardId}/{cardId}:
    get:
      summary: Get a card
      description: Returns a single card by its ID.
      operationId: getCard
      tags: [Cards]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
        - name: cardId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Card data.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/Card"
        "401":
          description: Invalid or missing API key.
        "403":
          description: API access is disabled for this board.
        "404":
          description: Card not found.
    delete:
      summary: Archive a card
      description: |
        Archives the card. This is a soft delete — the card is never removed, only marked
        archived (same as the "Delete" action in the Aptly UI, which archives rather than
        hard-deletes).
      operationId: deleteCard
      tags: [Cards]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
        - name: cardId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Card archived.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      _id:
                        type: string
                      archived:
                        type: boolean
        "401":
          description: Invalid or missing API key.
        "403":
          description: API access is disabled for this board.
        "404":
          description: Card not found.

  /api/board/{boardId}/{cardId}/comments:
    get:
      summary: List comments on a card
      description: Returns all comments on the specified card in chronological order.
      operationId: listComments
      tags: [Cards]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
        - name: cardId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Array of comments.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Comment"
              example:
                data:
                  - id: "lk3jf8"
                    userId: "user_abc"
                    content: "Application approved."
                    createdAt: "2026-01-15T10:30:00Z"
        "401":
          description: Invalid or missing API key.
        "403":
          description: API access is disabled for this board.
        "404":
          description: Board or card not found.

  /api/board/{boardId}/{cardId}/contacts:
    get:
      summary: List contacts linked to a card
      description: |
        Returns all person contacts linked to the card via its person/persons fields.
        Returns an empty array if the board has no person fields or none are populated.
      operationId: listCardContacts
      tags: [Cards]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
        - name: cardId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Array of linked contacts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Contact"
        "401":
          description: Invalid or missing API key.
        "403":
          description: API access is disabled for this board.
        "404":
          description: Board or card not found.

  /api/board/{boardId}/{cardId}/comment:
    post:
      summary: Add or update a comment
      description: |
        Adds a new comment to a card. To update an existing comment, include its `id` in the body —
        the `userId` must match the original comment's author.
      operationId: postComment
      tags: [Cards]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
        - name: cardId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [userId, content]
              properties:
                userId:
                  type: string
                  description: Aptly user ID of the comment author.
                content:
                  type: string
                  description: Comment text.
                id:
                  type: string
                  description: Existing comment ID — include to update rather than create.
      responses:
        "200":
          description: Comment created or updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/Comment"
        "401":
          description: Invalid or missing API key.
        "404":
          description: Card or comment not found.

  /api/board/{boardId}/{cardId}/file:
    post:
      summary: Upload a file to a card
      description: |
        Uploads a file and attaches it to a card. Send as `multipart/form-data` with the file in the `file` field.

        **Max file size:** 50 MB

        **Accepted types:** JPEG, PNG, GIF, WebP, SVG, PDF, Word, Excel, plain text, CSV, MP4, MOV, MP3, WAV
      operationId: uploadFile
      tags: [Cards]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
        - name: cardId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
      responses:
        "200":
          description: File uploaded and attached.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      fileId:
                        type: string
                      name:
                        type: string
                      size:
                        type: integer
                      type:
                        type: string
        "400":
          description: Missing file or unsupported file type.
        "401":
          description: Invalid or missing API key.
        "404":
          description: Card not found.

  /api/board/{boardId}/configuration/tabViews:
    post:
      summary: Add a tab view
      description: Adds an embedded tab view to the board's tab list.
      operationId: postTabView
      tags: [Board]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, url]
              properties:
                name:
                  type: string
                  description: Display name for the tab.
                url:
                  type: string
                  description: URL to embed in the tab.
                icon:
                  type: string
                  description: Font Awesome icon class (default `fa-regular fa-globe`).
                embedSource:
                  type: string
                  description: Embed source identifier (default `aptly-ai`).
                filter:
                  type: string
                  description: Filter preset (default `quickview_all-records`).
                userIds:
                  type: array
                  items:
                    type: string
                  description: User IDs that can see this tab. Empty = visible to all.
                createdBy:
                  type: string
                  description: Aptly user ID of the creator.
      responses:
        "200":
          description: Tab view created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/TabView"
        "400":
          description: Missing required fields.
        "401":
          description: Invalid or missing API key.
        "404":
          description: Board not found.

  /api/board/{boardId}/tabView:
    post:
      summary: Add a tab view (legacy)
      description: |
        Deprecated alias for `POST /api/board/{boardId}/configuration/tabViews`. Adds an
        embedded tab view to the board's tab list. Use the `/configuration/tabViews` path
        for new integrations.
      operationId: postTabViewLegacy
      tags: [Board]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, url]
              properties:
                name:
                  type: string
                  description: Display name for the tab.
                url:
                  type: string
                  description: URL to embed in the tab.
                icon:
                  type: string
                  description: Font Awesome icon class (default `fa-regular fa-globe`).
                embedSource:
                  type: string
                  description: Embed source identifier (default `aptly-ai`).
                filter:
                  type: string
                  description: Filter preset (default `quickview_all-records`).
                userIds:
                  type: array
                  items:
                    type: string
                  description: User IDs that can see this tab. Empty = visible to all.
                createdBy:
                  type: string
                  description: Aptly user ID of the creator.
      responses:
        "200":
          description: Tab view created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/TabView"
        "400":
          description: Missing required fields.
        "401":
          description: Invalid or missing API key.
        "404":
          description: Board not found.

  /api/board/verify-user:
    post:
      summary: Verify a delegate token
      description: |
        Validates a short-lived delegate token issued by `POST /api/platform/user-token`.
        Checks the JWT signature, expiry, and confirms the token was issued for the same
        company as the API key. Use this to confirm the identity of an authenticated Aptly
        user inside an embedded plugin or iframe.
      operationId: verifyAppUser
      tags: [Board]
      security:
        - ApiKeyHeader: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token:
                  type: string
                  description: Delegate token returned by `POST /api/platform/user-token`.
      responses:
        "200":
          description: Token is valid. Returns the user identity.
          content:
            application/json:
              schema:
                type: object
                properties:
                  userId:
                    type: string
                  email:
                    type: string
                    nullable: true
                  firstName:
                    type: string
                    nullable: true
                  lastName:
                    type: string
                    nullable: true
                  companyId:
                    type: string
        "400":
          description: token field is missing.
        "401":
          description: Invalid or missing API key, or delegate token is invalid/expired/wrong company.

  /api/company/info:
    get:
      summary: Get company info
      description: Returns the name, address, contact details, and logo URL for the company associated with the API key.
      operationId: getCompanyInfo
      tags: [Company]
      security:
        - ApiKeyHeader: []
      responses:
        "200":
          description: Company information.
          content:
            application/json:
              schema:
                type: object
                properties:
                  name:
                    type: string
                    nullable: true
                  address:
                    type: object
                    properties:
                      street:
                        type: string
                        nullable: true
                      city:
                        type: string
                        nullable: true
                      state:
                        type: string
                        nullable: true
                      zip:
                        type: string
                        nullable: true
                  phone:
                    type: string
                    nullable: true
                  phone2:
                    type: string
                    nullable: true
                  website:
                    type: string
                    nullable: true
                  logo:
                    type: string
                    nullable: true
                    description: Absolute URL to the company logo thumbnail.
        "401":
          description: Invalid or missing API key.

  /api/contacts/conversations:
    post:
      description: Find real email, SMS, and call conversations by the contacts’
        stored email addresses and phone numbers, without a helpdesk ticket
        requirement. Includes open and closed threads by default, with no
        recent-date cutoff. Excludes junk/deleted streams and synthetic
        bulk-campaign activity. Newest message first, across accessible inboxes.
        API keys have company scope; delegate tokens need contacts:* read scope
        and follow current membership, inbox/team/monitor and contact-type sharing
        permissions (respecting channel opt-outs). Every contact must be active
        and in the authenticated company. Empty addresses return an empty page.
      tags: &a1
        - Contacts
      security: &a2
        - ApiKeyHeader: []
        - DelegateToken: []
      responses: &a3
        "200":
          description: A page of matching conversations.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ContactConversationsResponse"
        "400":
          description: Invalid contact IDs, filters or pagination.
        "401":
          description: Invalid or missing credentials.
        "403":
          description: Missing scope, inactive user membership, guest user, or
            inaccessible inbox.
        "404":
          description: One or more contacts are missing, archived, deleted or outside the company.
      summary: Get conversations for contacts
      operationId: getConversationsForContacts
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - personIds
              properties:
                personIds:
                  type: array
                  minItems: 1
                  maxItems: 20
                  items:
                    type: string
                    minLength: 1
                    maxLength: 128
                status: &a4
                  type: string
                  enum:
                    - all
                    - open
                    - closed
                  default: all
                inboxId: &a5
                  type: string
                  description: Channel meta.id; narrows authorized inboxes, never expands access.
                search: &a6
                  type: string
                  maxLength: 200
                  description: Subject contains text; comma-separated literal alternatives.
                skip: &a7
                  type: integer
                  minimum: 0
                  maximum: 100000
                  default: 0
                limit: &a8
                  type: integer
                  minimum: 1
                  maximum: 60
                  default: 20
            example:
              personIds:
                - contact-1
                - contact-2
              status: all
              skip: 0
              limit: 20
  /api/contacts/{contactId}/conversations:
    get:
      description: Find real email, SMS, and call conversations by the contacts’
        stored email addresses and phone numbers, without a helpdesk ticket
        requirement. Includes open and closed threads by default, with no
        recent-date cutoff. Excludes junk/deleted streams and synthetic
        bulk-campaign activity. Newest message first, across accessible inboxes.
        API keys have company scope; delegate tokens need contacts:* read scope
        and follow current membership, inbox/team/monitor and contact-type sharing
        permissions (respecting channel opt-outs). Every contact must be active
        and in the authenticated company. Empty addresses return an empty page.
      tags: *a1
      security: *a2
      responses: *a3
      summary: Get contact conversations
      operationId: getContactConversations
      parameters:
        - name: contactId
          in: path
          required: true
          schema:
            type: string
            minLength: 1
            maxLength: 128
        - name: status
          in: query
          schema: *a4
        - name: inboxId
          in: query
          schema: *a5
        - name: search
          in: query
          schema: *a6
        - name: skip
          in: query
          schema: *a7
        - name: limit
          in: query
          schema: *a8

  /api/contacts:
    get:
      summary: List contacts
      description: |
        Returns a paginated list of contacts scoped to your company. All filter params are optional and ANDed together.
      operationId: listContacts
      tags: [Contacts]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      parameters:
        - name: page
          in: query
          required: true
          schema:
            type: integer
            minimum: 0
          description: Zero-based page index.
        - name: contact_type
          in: query
          schema:
            type: string
          description: Filter by contact type name.
        - name: email
          in: query
          schema:
            type: string
          description: Filter by exact email address (case-insensitive).
        - name: phone
          in: query
          schema:
            type: string
          description: Filter by phone number (digits only, partial match).
        - name: name
          in: query
          schema:
            type: string
          description: Filter by full name (case-insensitive, all words must match).
        - name: updated_after
          in: query
          schema:
            type: string
            format: date-time
          description: Return only contacts updated after this ISO 8601 timestamp.
        - name: updated_before
          in: query
          schema:
            type: string
            format: date-time
          description: Return only contacts updated before this ISO 8601 timestamp.
      responses:
        "200":
          description: Paginated contact list.
          headers:
            x-offset:
              schema:
                type: integer
              description: Current page index.
            x-count:
              schema:
                type: integer
              description: Total matching contacts.
            x-size:
              schema:
                type: integer
              description: Page size.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                  count:
                    type: integer
                  page:
                    type: integer
                  pageSize:
                    type: integer
        "400":
          description: Missing or invalid parameters.
        "401":
          description: Invalid or missing API key.

    post:
      summary: Create or update a contact
      description: |
        Creates a new contact or updates an existing one (upsert).

        **Lookup order:**
        1. If `_id` is provided, finds by ID.
        2. Otherwise finds by first email address.
        3. If no match is found, creates a new contact.

        **Body formats** — either native or legacy (capitalized keys) are accepted:

        *Native:* `firstname`, `lastname`, `email` (string or array), `phone` (array of `{number, type}`),
        `typeId`, `contactType`, `isCompany`, `title`, `company`, `imageUrl`, `customFields`

        *Legacy:* `"First Name"`, `"Last Name"`, `Email`, `"Mobile Phone"`, `"Work Phone"`,
        `"Home Phone"`, `"Contact Type"`, `Title`, `Company`

        Returns the enriched contact with custom fields populated by their type definitions.
      operationId: upsertContact
      tags: [Contacts]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                _id:
                  type: string
                  description: Existing contact ID — when provided, updates that contact.
                firstname:
                  type: string
                lastname:
                  type: string
                email:
                  oneOf:
                    - type: string
                    - type: array
                      items:
                        type: string
                  description: One or more email addresses.
                phone:
                  type: array
                  items:
                    type: object
                    properties:
                      number:
                        type: string
                      type:
                        type: string
                        enum: [mobile, work, home]
                typeId:
                  type: string
                  description: Contact type ID.
                contactType:
                  type: string
                  description: Contact type name (alternative to `typeId` — resolved to an ID automatically).
                isCompany:
                  type: boolean
                title:
                  type: string
                company:
                  type: string
                imageUrl:
                  type: string
                  description: Absolute URL to a JPG or PNG photo.
                customFields:
                  type: object
                  description: Map of custom field ID to value. Unknown field IDs are rejected.
            example:
              firstname: "Jane"
              lastname: "Smith"
              email: "jane@example.com"
              phone:
                - number: "+15555550100"
                  type: "mobile"
              contactType: "Tenant"
      responses:
        "200":
          description: Contact created or updated.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Contact"
        "400":
          description: Invalid input (bad URL, unknown custom field, invalid date, etc.).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Invalid or missing API key.

  /api/contacts/by-email:
    post:
      summary: Look up contacts by email
      description: |
        Returns contacts whose email address matches one or more of the provided values.
        Matching is case-insensitive and exact. Results are scoped to your company.
      operationId: getContactsByEmail
      tags: [Contacts]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  oneOf:
                    - type: string
                    - type: array
                      items:
                        type: string
                  description: A single email address or an array of email addresses to look up.
                limit:
                  type: integer
                  default: 20
                  description: Maximum number of results to return.
                skip:
                  type: integer
                  default: 0
                  description: Number of results to skip (for pagination).
            example:
              email: "jane@example.com"
              limit: 20
              skip: 0
      responses:
        "200":
          description: Matching contacts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  contacts:
                    type: array
                    items:
                      $ref: "#/components/schemas/Contact"
        "400":
          description: Missing or invalid email field.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Invalid or missing API key.

  /api/contacts/verify-email:
    post:
      summary: Initiate contact email verification
      description: |
        Looks up an email address against your org's contact database. If a match is found,
        generates a cryptographically strong 6-digit code, sends it to the address, and
        returns a `requestId` and `verifyUrl` to complete the verification.

        The code expires after 10 minutes and can only be used once.
      operationId: initiateContactVerification
      tags: [Contacts]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  description: The email address to verify.
                emailSubject:
                  type: string
                  description: Subject line for the verification email. Defaults to "Your verification code".
                replyTo:
                  type: string
                  description: Reply-To address for the verification email.
                emailHtml:
                  type: string
                  description: |
                    Custom HTML body for the verification email. Supports two placeholders:
                    `{{ verificationCode }}` — replaced with the 6-digit code.
                    `{{ expirationTime }}` — replaced with the expiry duration (e.g. "10 minutes").
            example:
              email: "jane@example.com"
              emailSubject: "Your access code"
              replyTo: "support@example.com"
              emailHtml: "<p>Your code is <strong>{{ verificationCode }}</strong>. It expires in {{ expirationTime }}.</p>"
      responses:
        "200":
          description: Verification initiated — code sent to the email address.
          content:
            application/json:
              schema:
                type: object
                properties:
                  requestId:
                    type: string
                    description: Opaque ID used to complete the verification.
                  verifyUrl:
                    type: string
                    description: API path to POST the code to (relative URL).
              example:
                requestId: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"
                verifyUrl: "/api/contacts/verify-email/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4/confirm"
        "400":
          description: Missing or invalid email field.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: No contact found with that email address.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Verification request was created but the email could not be delivered (EMAIL_SEND_FAILURE).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/contacts/verify-email/{requestId}/confirm:
    post:
      summary: Confirm contact email verification
      description: |
        Submits the 6-digit code received by email. Returns the matching contact records
        if the code is valid, not expired, and has not already been used.

        After 5 consecutive failed attempts the verification is permanently invalidated.
        The caller must re-initiate a new verification to try again.
      operationId: confirmContactVerification
      tags: [Contacts]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      parameters:
        - name: requestId
          in: path
          required: true
          schema:
            type: string
          description: The `requestId` returned by the initiate endpoint.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code:
                  type: string
                  description: The 6-digit verification code sent to the email address.
            example:
              code: "042815"
      responses:
        "200":
          description: Code accepted — returns verified status and matching contacts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  verified:
                    type: boolean
                    example: true
                  contacts:
                    type: array
                    items:
                      $ref: "#/components/schemas/Contact"
        "400":
          description: Missing code field.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Invalid or missing API key, or code is invalid/expired/already used.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/contacts/{contactId}:
    get:
      summary: Get a contact
      description: Returns a single contact by its ID, with custom fields enriched by their type definitions.
      operationId: getContact
      tags: [Contacts]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      parameters:
        - name: contactId
          in: path
          required: true
          schema:
            type: string
          description: The contact's `_id`.
      responses:
        "200":
          description: Contact record.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Contact"
                  count:
                    type: integer
                  page:
                    type: integer
                  pageSize:
                    type: integer
        "401":
          description: Invalid or missing API key.

    post:
      summary: Update a contact
      description: |
        Updates an existing contact by ID using the same upsert logic as `POST /api/contacts`.
        The `_id` is taken from the URL — any `_id` in the body is ignored.

        Accepts the same **native** or **legacy** body formats as `POST /api/contacts`.
        Returns the updated, enriched contact.
      operationId: updateContact
      tags: [Contacts]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      parameters:
        - name: contactId
          in: path
          required: true
          schema:
            type: string
          description: The contact's `_id`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                firstname:
                  type: string
                lastname:
                  type: string
                email:
                  oneOf:
                    - type: string
                    - type: array
                      items:
                        type: string
                  description: One or more email addresses.
                phone:
                  type: array
                  items:
                    type: object
                    properties:
                      number:
                        type: string
                      type:
                        type: string
                        enum: [mobile, work, home]
                typeId:
                  type: string
                  description: Contact type ID.
                contactType:
                  type: string
                  description: Contact type name (alternative to `typeId`).
                isCompany:
                  type: boolean
                title:
                  type: string
                company:
                  type: string
                imageUrl:
                  type: string
                  description: Absolute URL to a JPG or PNG photo.
                customFields:
                  type: object
                  description: Map of custom field ID to value.
            example:
              firstname: "Jane"
              lastname: "Smith"
              email: "jane@example.com"
      responses:
        "200":
          description: Updated contact.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Contact"
        "400":
          description: Invalid input.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Invalid or missing API key.

  /api/contacts/{contactId}/sentiment:
    get:
      summary: Get a contact's sentiment history
      description: |
        Returns the contact's conversation sentiment over the last 30 days, merged into a
        single series across every channel in the company and keyed off the addresses in the
        contact's `email` array.

        One point per day, oldest first. `score` is the average of the day's streams — each
        stream contributes one score, so a long conversation does not outweigh a short one —
        rounded to a whole number on a 0-100 scale. `overall` is the recency-weighted average
        across the window: today counts fully, a day at the edge of the window counts half.
        `totalStreams` counts the distinct conversations in the window, so a conversation
        spanning several days is counted once.

        A contact with no email addresses, or with no scored conversations in the window,
        returns `overall: null` and an empty `history`.
      operationId: getContactSentiment
      tags: [Contacts]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      parameters:
        - name: contactId
          in: path
          required: true
          schema:
            type: string
          description: The contact's `_id`.
      responses:
        "200":
          description: Sentiment history for the contact.
          content:
            application/json:
              schema:
                type: object
                properties:
                  overall:
                    type: integer
                    nullable: true
                    description: Recency-weighted average score (0-100) across the window.
                  totalStreams:
                    type: integer
                    description: Distinct conversations behind the window.
                  history:
                    type: array
                    items:
                      type: object
                      properties:
                        date:
                          type: string
                          format: date-time
                        score:
                          type: integer
                          description: Average score (0-100) for the day.
                        streams:
                          type: integer
                          description: Distinct conversations behind the day.
              example:
                overall: 76
                totalStreams: 2
                history:
                  - date: "2026-08-15T00:00:00.000Z"
                    score: 40
                    streams: 2
                  - date: "2026-09-04T00:00:00.000Z"
                    score: 100
                    streams: 1
        "401":
          description: Invalid or missing API key.
        "404":
          description: Contact not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/knowledge/create:
    post:
      summary: Create a knowledge document
      description: |
        Creates a new knowledge document scoped to your company. Optionally associates the
        document with a board (`aptletUuid`), a card (`aptletInstanceId`), or a parent
        document (`parentId`).

        HTML or Markdown content is accepted and stored internally as a structured document
        format. Provide either `html` or `markdown`, not both. Aptly custom blocks (cards,
        tasks, contacts, mentions, merge fields, form fields, embeds, diagrams) are written
        in `html` as described under "HTML format" in `PUT /api/knowledge/{id}`.
      operationId: createKnowledgeDoc
      tags: [Knowledge]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              not:
                required: [html, markdown]
              properties:
                name:
                  type: string
                  description: Document title.
                html:
                  type: string
                  description: Initial HTML content for the document body. Mutually exclusive with `markdown`.
                markdown:
                  type: string
                  description: Initial Markdown content for the document body. Mutually exclusive with `html`.
                aptletUuid:
                  type: string
                  description: UUID of the board to associate this document with.
                aptletInstanceId:
                  type: string
                  description: Card ID to link this document to. Creates a reference on the card.
                parentId:
                  type: string
                  description: ID of a parent knowledge document (for nested pages).
                accessType:
                  type: string
                  enum: [public, private]
                  description: Access level. Defaults to `public`.
            example:
              name: "Lease Addendum Policy"
              html: "<p>This policy applies to all lease agreements...</p>"
              accessType: "private"
      responses:
        "201":
          description: Document created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  _id:
                    type: string
                    description: ID of the created document.
                  name:
                    type: string
        "400":
          description: Both `html` and `markdown` were provided (only one is allowed).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Card or parent document not found (when `aptletInstanceId` or `parentId` provided).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/knowledge/list:
    get:
      summary: List knowledge documents
      description: |
        Lists knowledge documents scoped to your company — a folder's contents, or
        everything linked to a board, card, or conversation — in a lite shape (no rendered
        HTML), paginated.

        Access is scoped per-user when authenticated with a delegate token; API keys and
        partner tokens see every document in the company regardless of its private-doc
        shares.
      operationId: listKnowledgeDocs
      tags: [Knowledge]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: parentId
          in: query
          schema:
            type: string
          description: >
            Omit to list every document regardless of nesting. Pass "" or "null" to list
            root documents only, or a document ID to list that folder's contents.
        - name: aptletUuid
          in: query
          schema:
            type: string
          description: Only documents associated with this board.
        - name: aptletInstanceId
          in: query
          schema:
            type: string
          description: Only documents linked to this card.
        - name: streamId
          in: query
          schema:
            type: string
          description: Only documents extracted from this conversation.
        - name: search
          in: query
          schema:
            type: string
          description: >
            Case-insensitive substring match on document name. Regex metacharacters are
            escaped before being interpolated into the query, so the match is always
            literal.
        - name: archived
          in: query
          schema:
            type: boolean
          description: Set true to list archived documents instead of active ones.
        - name: ownerId
          in: query
          schema:
            type: string
          description: Only documents created by this user (matches `createdBy`).
        - name: updatedById
          in: query
          schema:
            type: string
          description: Only documents last updated by this user (matches `updatedBy`).
        - name: createdFrom
          in: query
          schema:
            type: string
            format: date-time
          description: Only documents created on or after this ISO date (inclusive).
        - name: createdTo
          in: query
          schema:
            type: string
            format: date-time
          description: Only documents created on or before this ISO date (inclusive).
        - name: updatedFrom
          in: query
          schema:
            type: string
            format: date-time
          description: >
            Only documents last updated on or after this ISO date (inclusive). Filters
            `updatedAt` only, never `createdAt`.
        - name: updatedTo
          in: query
          schema:
            type: string
            format: date-time
          description: >
            Only documents last updated on or before this ISO date (inclusive). Filters
            `updatedAt` only, never `createdAt`.
        - name: unlinked
          in: query
          schema:
            type: boolean
          description: >
            Set true to list only documents with no `aptletUuid`, `aptletInstanceId`, or
            `streamId` — the knowledge base's own top-level documents ("pane 0"), as
            opposed to documents that hang off a board, card, or conversation. Cannot be
            combined with `aptletUuid`, `aptletInstanceId`, or `streamId`.
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
            maximum: 200
          description: >
            A non-numeric value is rejected with 400 rather than silently defaulting.
            A valid non-integer value (e.g. 10.5) is truncated.
        - name: skip
          in: query
          schema:
            type: integer
            default: 0
            minimum: 0
          description: >
            A non-numeric or negative value is rejected with 400 rather than silently
            coercing to 0. A valid non-integer value is truncated.
      responses:
        "200":
          description: Paginated list of knowledge document summaries.
          content:
            application/json:
              schema:
                type: object
                properties:
                  docs:
                    type: array
                    items:
                      $ref: "#/components/schemas/KnowledgeDocSummary"
                  total:
                    type: integer
                  limit:
                    type: integer
                  skip:
                    type: integer
        "400":
          description: >
            Invalid input — an unparseable date, `to` not after `from`, `unlinked=true`
            combined with `aptletUuid`/`aptletInstanceId`/`streamId`, or a non-numeric
            `limit`/`skip` (or a negative `skip`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/knowledge/tree:
    get:
      summary: Get the knowledge document tree
      description: |
        Returns a lazy, depth-bounded tree of knowledge documents starting at `parentId`
        (root documents when omitted). Each node reports `hasChildren` so you can decide
        whether to fetch further levels via `GET /api/knowledge/{id}/children`.

        Accepts the same filter parameters as `GET /api/knowledge/list` (everything below
        except `limit`/`skip` — `parentId` here keeps its tree-specific meaning, the root
        to start from, not a filter). With no filter active, the response is the plain
        tree with no `matched` field on any node. With a filter active, the tree (already
        built and depth-bounded as usual) is pruned to documents that match the filter,
        plus their ancestors (so a match stays reachable from the root) and their
        descendants (so a matched folder's contents stay visible) — every kept node
        within the tree, matching or not, gets a `matched` boolean. A document that would
        only appear more than `depth` levels below the root is never fetched, so it can't
        match either — increase `depth` to search deeper.
      operationId: getKnowledgeTree
      tags: [Knowledge]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: parentId
          in: query
          schema:
            type: string
          description: Omit, or pass "" / "null", for the root level.
        - name: depth
          in: query
          schema:
            type: integer
            default: 1
            maximum: 3
          description: How many levels to expand eagerly.
        - name: aptletUuid
          in: query
          schema:
            type: string
          description: Only match documents associated with this board.
        - name: aptletInstanceId
          in: query
          schema:
            type: string
          description: Only match documents linked to this card.
        - name: streamId
          in: query
          schema:
            type: string
          description: Only match documents extracted from this conversation.
        - name: search
          in: query
          schema:
            type: string
          description: >
            Case-insensitive substring match on document name. Regex metacharacters are
            escaped before being interpolated into the query, so the match is always
            literal.
        - name: archived
          in: query
          schema:
            type: boolean
          description: >
            Set true to match archived documents instead of active ones. Also widens tree
            traversal itself so archived documents (and their archived descendants) are
            reachable at all.
        - name: ownerId
          in: query
          schema:
            type: string
          description: Only match documents created by this user (matches `createdBy`).
        - name: updatedById
          in: query
          schema:
            type: string
          description: Only match documents last updated by this user (matches `updatedBy`).
        - name: createdFrom
          in: query
          schema:
            type: string
            format: date-time
          description: Only match documents created on or after this ISO date (inclusive).
        - name: createdTo
          in: query
          schema:
            type: string
            format: date-time
          description: Only match documents created on or before this ISO date (inclusive).
        - name: updatedFrom
          in: query
          schema:
            type: string
            format: date-time
          description: >
            Only match documents last updated on or after this ISO date (inclusive).
            Filters `updatedAt` only, never `createdAt`.
        - name: updatedTo
          in: query
          schema:
            type: string
            format: date-time
          description: >
            Only match documents last updated on or before this ISO date (inclusive).
            Filters `updatedAt` only, never `createdAt`.
        - name: unlinked
          in: query
          schema:
            type: boolean
          description: >
            Set true to match only documents with no `aptletUuid`, `aptletInstanceId`, or
            `streamId` — the knowledge base's own top-level documents ("pane 0"). Cannot
            be combined with `aptletUuid`, `aptletInstanceId`, or `streamId`.
      responses:
        "200":
          description: Tree of knowledge document nodes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  nodes:
                    type: array
                    items:
                      $ref: "#/components/schemas/KnowledgeTreeNode"
        "400":
          description: >
            Invalid input — an unparseable date, `to` not after `from`, or `unlinked=true`
            combined with `aptletUuid`/`aptletInstanceId`/`streamId`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/knowledge/{id}/children:
    get:
      summary: List a knowledge document's direct children
      description: Returns the direct children of one knowledge document as a flat, lite-shape list.
      operationId: getKnowledgeDocChildren
      tags: [Knowledge]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: The knowledge document (folder) ID.
        - name: archived
          in: query
          schema:
            type: boolean
            default: false
          description: >
            When true, include archived documents: the parent lookup no longer 404s on an
            archived parent, and archived children are included. Needed to expand a
            `hasChildren: true` archived folder returned by `GET /api/knowledge/tree`
            with `archived=true`.
      responses:
        "200":
          description: The document's direct children.
          content:
            application/json:
              schema:
                type: object
                properties:
                  docs:
                    type: array
                    items:
                      $ref: "#/components/schemas/KnowledgeDocSummary"
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Document not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/knowledge/{id}/history:
    get:
      operationId: listKnowledgeDocHistory
      summary: List saved knowledge document versions
      description: "Reads retained saved snapshots of a non-archived knowledge document. Partner tokens require knowledge-docs; API keys or delegate tokens use knowledge read access. Delegate callers must remain active company members and currently be able to view the document; membership, document and team authorization use primary reads. API keys and partner tokens are company-scoped. Archived, deleted, or inaccessible documents return 404. There is no archived override. Snapshots older than the current document creation time are excluded so a reused document ID cannot expose a deleted document history. These are saved versions, not unsaved drafts or a complete edit audit: rapid saves may merge and older history may be pruned (normally 50 versions retained). savedBy records the saver, not necessarily the author of every passage; Hiro before/after snapshots both name the approver. No restore or mutation is performed. Versions sort by savedAt descending, then version ID descending. Date bounds are inclusive instants; date-only values mean midnight UTC without end-of-day expansion. A filtered list may omit a predecessor needed for a comparison: omit the date filter to retrieve earlier versions. Metadata only; use the version detail endpoint for text."
      tags:
        - Knowledge
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: id
          in: path
          required: true
          description: Current knowledge document ID.
          schema: &a1
            type: string
        - &a2
          name: companyId
          in: query
          description: Required with a partner token; API key/delegate credentials resolve company scope.
          schema: *a1
        - name: savedFrom
          in: query
          description: Inclusive earliest save time (ISO date or timestamp).
          schema: *a1
        - name: savedTo
          in: query
          description: Inclusive latest save time (ISO date or timestamp).
          schema: *a1
        - name: savedBy
          in: query
          description: Recorded saver user ID.
          schema: *a1
        - name: limit
          in: query
          description: Maximum versions per page.
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 20
        - name: offset
          in: query
          description: Pagination offset from nextOffset.
          schema:
            type: integer
            minimum: 0
            maximum: 10000
            default: 0
      responses:
        "200":
          description: "Saved history. Nullable fields and nextOffset: null are preserved."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/KnowledgeHistoryList"
        "400":
          description: Invalid filters/pagination, or (detail only) an unsupported historical content format.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Missing or invalid authentication.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Credential lacks knowledge read access.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Document inaccessible, archived, or absent; or the requested version does not belong to this document and company.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /api/knowledge/{id}/history/{versionId}:
    get:
      operationId: getKnowledgeDocVersion
      summary: Read a saved knowledge document version
      description: "Reads retained saved snapshots of a non-archived knowledge document. Partner tokens require knowledge-docs; API keys or delegate tokens use knowledge read access. Delegate callers must remain active company members and currently be able to view the document; membership, document and team authorization use primary reads. API keys and partner tokens are company-scoped. Archived, deleted, or inaccessible documents return 404. There is no archived override. Snapshots older than the current document creation time are excluded so a reused document ID cannot expose a deleted document history. These are saved versions, not unsaved drafts or a complete edit audit: rapid saves may merge and older history may be pruned (normally 50 versions retained). savedBy records the saver, not necessarily the author of every passage; Hiro before/after snapshots both name the approver. No restore or mutation is performed. Returns historical metadata and read-only top-level text blocks, up to 20 blocks and 20,000 text characters per page. Each block is capped at 10,000 characters with explicit truncation; specialized blocks may omit non-text/formatting details. offset/nextOffset count blocks. No editable flags or current editing-version token are returned. Unsupported legacy snapshots return 400. The top-level name is the current title; snapshot.name is the historical title. Any document link opens the current document."
      tags:
        - Knowledge
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: id
          in: path
          required: true
          description: Current knowledge document ID.
          schema: *a1
        - name: versionId
          in: path
          required: true
          description: Saved version ID returned by the history list.
          schema: *a1
        - *a2
        - name: offset
          in: query
          description: Top-level block offset from nextOffset.
          schema:
            type: integer
            minimum: 0
            maximum: 10000
            default: 0
      responses:
        "200":
          description: "Saved history. Nullable fields and nextOffset: null are preserved."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/KnowledgeHistoryDetail"
        "400":
          description: Invalid filters/pagination, or (detail only) an unsupported historical content format.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Missing or invalid authentication.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Credential lacks knowledge read access.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Document inaccessible, archived, or absent; or the requested version does not belong to this document and company.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/knowledge/{id}:
    get:
      summary: Get a knowledge document
      description: |
        Returns a knowledge document's full detail — folder path, shares, direct children,
        links to any board/card/conversation, view counts, and content rendered as HTML.

        `currentUser` is populated only when authenticated with a delegate token (its
        `userId` is known); it is `null` for API keys and partner tokens, which are
        company-wide credentials with no per-user permission check. A private document you
        cannot view returns 404, the same as one that doesn't exist. Unlike `list`/`tree`/
        `children`, this is a direct by-id fetch, so an archived document is still
        returned (with `archived: true`) rather than 404ing.
      operationId: getKnowledgeDoc
      tags: [Knowledge]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: The knowledge document ID.
      responses:
        "200":
          description: Knowledge document detail.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/KnowledgeDoc"
              example:
                _id: "kdoc_abc123"
                name: "Lease Addendum Policy"
                emoji: null
                iconUrl: null
                coverUrl: null
                accessType: "public"
                isPrivate: false
                shares: []
                parentId: null
                path: []
                children: []
                childCount: 0
                links:
                  aptletUuid: null
                  aptletInstanceId: null
                  streamId: null
                published: false
                publishedAt: null
                views:
                  internal: 0
                  public: 0
                archived: false
                archivedAt: null
                createdAt: "2026-01-05T12:00:00.000Z"
                createdBy: "user_123"
                updatedAt: "2026-01-05T12:00:00.000Z"
                updatedBy: "user_123"
                currentUser: null
                html: "<p>This policy applies to all lease agreements...</p>"
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Document not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

    put:
      summary: Update a knowledge document
      description: |
        Updates a knowledge document's content and/or metadata. Only fields provided in the
        request body are updated — omitted fields are left unchanged. Provide either `html`
        or `markdown`, not both.

        ## HTML format

        Standard HTML (paragraphs, headings, lists, check lists, tables, quotes, code blocks,
        images, links, bold/italic/underline/strike) is converted to the editor's built-in
        blocks. Aptly's custom blocks and inline items are written as elements carrying a
        type attribute plus one `data-*` attribute per property. Property names are the
        camelCase names below written in kebab-case (`cardId` → `data-card-id`). Omitted
        properties take their default (empty string unless noted). Text inside a block
        element is ignored, except for the `ai` block. `GET /api/knowledge/{id}` returns
        these elements in the same format, so its `html` can be edited and sent back.

        **Blocks** — `<div data-content-type="TYPE" ...></div>`, placed between paragraphs:

        | Type | Properties | Example |
        | --- | --- | --- |
        | `card` | `cardId` | `<div data-content-type="card" data-card-id="CARD_ID"></div>` |
        | `task` | `taskId` | `<div data-content-type="task" data-task-id="TASK_ID"></div>` |
        | `contact` | `contactId` | `<div data-content-type="contact" data-contact-id="CONTACT_ID"></div>` |
        | `pageLink` | `documentId` (another knowledge document) | `<div data-content-type="pageLink" data-document-id="DOC_ID"></div>` |
        | `aptlyObject` | `objectId`, `objectType`, `aptletUuid` | `<div data-content-type="aptlyObject" data-object-id="ID" data-object-type="card" data-aptlet-uuid="BOARD_UUID"></div>` |
        | `embed` | `url`, `width` (number, default 500), `height` (number, default 400) | `<div data-content-type="embed" data-url="https://example.com" data-width="600" data-height="400"></div>` |
        | `mermaid` | `code` (Mermaid diagram source) | `<div data-content-type="mermaid" data-code="graph TD; A--&gt;B"></div>` |
        | `formField` | `boardUuid`, `fieldUuid`, `fieldType`, `label`, `required` (boolean, default false), `cardId`, `boardName`, `boardIcon`, `boardColor` | `<div data-content-type="formField" data-board-uuid="BOARD_UUID" data-field-uuid="FIELD_UUID" data-label="Move-in date" data-required="true"></div>` |
        | `mergeFieldBlock` | `key`, `boardUuid`, `fieldUuid`, `cardId`, `label`, `boardName` | `<div data-content-type="mergeFieldBlock" data-key="KEY" data-label="Unit"></div>` |
        | `ai` | `prompt`; the element's text is the generated content | `<div data-content-type="ai" data-prompt="Summarize the lease"><p>Generated text</p></div>` |

        **Inline items** — `<span data-inline-content-type="TYPE" ...></span>`, inside a
        paragraph, heading, list item or table cell:

        | Type | Properties | Example |
        | --- | --- | --- |
        | `userMention` | `id`, `entityId`, `fullName` (default `Unknown`), `type` (default `user`), `mentionedBy` | `<span data-inline-content-type="userMention" data-id="USER_ID" data-full-name="Jane Doe"></span>` |
        | `cardMention` | `cardId` | `<span data-inline-content-type="cardMention" data-card-id="CARD_ID"></span>` |
        | `taskMention` | `taskId` | `<span data-inline-content-type="taskMention" data-task-id="TASK_ID"></span>` |
        | `mergeField` | `key`, `boardUuid`, `fieldUuid`, `cardId`, `label`, `boardName`, `fallback` (omit for no fallback) | `<span data-inline-content-type="mergeField" data-key="KEY" data-label="Rent"></span>` |

        Notes:
        - Escape attribute values as HTML (`&quot;`, `&amp;`, `&lt;`, `&gt;`).
        - `true`/`false` and numbers in attributes are read as booleans and numbers.
        - An element without its `data-content-type` / `data-inline-content-type`
          attribute is treated as plain HTML, not as the custom item.
        - The `ai` block keeps only the text of its first `<p>`.
        - Multi-column layouts are not supported.
        - Markdown cannot express custom blocks; use `html` for them.
      operationId: updateKnowledgeDoc
      tags: [Knowledge]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: The knowledge document ID.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              not:
                required: [html, markdown]
              properties:
                name:
                  type: string
                  description: New document title.
                html:
                  type: string
                  description: Replacement HTML content for the document body. Mutually exclusive with `markdown`.
                markdown:
                  type: string
                  description: Replacement Markdown content for the document body. Mutually exclusive with `html`.
                accessType:
                  type: string
                  enum: [public, private]
                  description: New access level.
            example:
              name: "Updated Lease Addendum Policy"
              html: "<p>Revised policy effective March 2026...</p>"
      responses:
        "200":
          description: Document updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  _id:
                    type: string
        "400":
          description: Both `html` and `markdown` were provided (only one is allowed).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Document not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/email/create-draft:
    post:
      summary: Create an email draft
      description: |
        Creates a new outbound email discussion (stream) with a single draft entry, scoped to
        your company. Returns the `streamId` and `draftUuid` needed to send via
        `POST /api/email/send`.

        Recipient fields (`to`, `cc`, `bcc`) accept an array of `{ value, label? }` objects
        where `value` is the email address.

        ### Adding attachments

        Attachments must be uploaded to storage **before** you reference them here — you
        cannot post file bytes to this endpoint. Upload each file with the three-step direct
        upload flow, then attach it:

        1. **Get an upload URL** — `POST /api/files/upload-url` with `attachEntityType: "channel"`,
           `attachEntityId` set to the same `channelId` you're emailing from, and the file's
           `name`, `extension`, `size`, and `contentType`. It returns `{ fileId, url, fields }`.
        2. **Upload to S3** — send a `multipart/form-data` `POST` to `url`, appending every
           entry from `fields` first and the file bytes last as a `file` field. S3 returns `204`.
        3. **Mark complete** — `POST /api/files/upload-complete` with the `fileId` from step 1.

        Then, on this request:
        - **Regular attachment** — add the `fileId`(s) to `attachmentIds`.
        - **Inline image** — embed an `<img>` in the HTML `body` whose `src` is the file's
          download URL (`.../cdn/storage/AptlyFiles/<fileId>/original/...`, returned by step 3).
          It is auto-detected and registered; do **not** also list it in `attachmentIds`.

        Repeat steps 1–3 per file. Each `fileId` must reference a finished upload or the
        request is rejected.
      operationId: createEmailDraft
      tags: [Email]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [userId, channelId]
              properties:
                userId:
                  type: string
                  description: User to record as the draft creator/sender.
                channelId:
                  type: string
                  description: "`channel.meta.id` of the outbound email channel."
                companyId:
                  type: string
                  description: Company ID. Required only when authenticating with a partner token; resolved automatically for API key and delegate token auth.
                discussionId:
                  type: string
                  description: |
                    When set, append the draft as a reply to this existing thread (stream
                    ID) instead of starting a new discussion. The thread must belong to the
                    same company and channel. The thread's subject is kept and the draft is
                    threaded onto the thread's last message.
                to:
                  type: array
                  items:
                    $ref: "#/components/schemas/EmailRecipient"
                cc:
                  type: array
                  items:
                    $ref: "#/components/schemas/EmailRecipient"
                bcc:
                  type: array
                  items:
                    $ref: "#/components/schemas/EmailRecipient"
                subject:
                  type: string
                body:
                  type: string
                  description: |
                    Email body (plain text or HTML). To embed an uploaded image inline, add
                    an `<img>` whose `src` is the file's download URL from
                    `POST /api/files/upload-complete`
                    (`.../cdn/storage/AptlyFiles/<fileId>/original/...`). Inline images are
                    auto-detected, tagged with `data-inline-image-id`, and registered on the
                    draft — do not also list them in `attachmentIds`.
                attachmentIds:
                  type: array
                  items:
                    type: string
                  description: |
                    File ids to include as regular attachments. Upload each file first
                    (`POST /api/files/upload-url` with attachEntityType `channel` and the same
                    channelId → upload to S3 → `POST /api/files/upload-complete`), then pass the
                    resulting `fileId`s here. Each must reference a finished upload.
                aptletInstanceId:
                  type: string
                  description: |
                    Card to link the outbound to. When set, sending the draft logs the
                    email as an activity on the card and tags the discussion with it.
            example:
              userId: "user_abc"
              channelId: "channel_abc"
              to:
                - value: "tenant@example.com"
                  label: "Jane Tenant"
              subject: "Your lease renewal"
              body: "Please review the attached renewal terms."
              aptletInstanceId: "card_abc"
      responses:
        "200":
          description: Draft stream created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  streamId:
                    type: string
                    description: Discussion/stream ID to pass to `POST /api/email/send`.
                  draftUuid:
                    type: string
                    description: Draft UUID to pass to `POST /api/email/send`.
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/email/send:
    post:
      summary: Send an email
      description: |
        Sends an outbound email scoped to your company. Two usage patterns:

        `userId` and `channelId` are required on every call.

        **From an existing draft** — also provide `discussionId` and `uuid` returned by
        `POST /api/email/create-draft`. The draft's stored recipients, subject, and body are
        used.

        **On-the-fly** — omit `uuid` and provide `userId`, `channelId`, recipients, subject,
        and body. A draft is created automatically before sending. Supplying `discussionId`
        (without `uuid`) sends the message as a reply into that existing thread — the
        thread's subject is kept; omitting both `discussionId` and `uuid` starts a new thread.

        ### Adding attachments

        Attachments apply to the **on-the-fly** pattern (for an existing draft, attach files
        when you call `POST /api/email/create-draft`). Upload each file with the three-step
        direct upload flow first — you cannot post file bytes to this endpoint:

        1. **Get an upload URL** — `POST /api/files/upload-url` with `attachEntityType: "channel"`,
           `attachEntityId` set to the same `channelId` you're sending from, and the file's
           `name`, `extension`, `size`, and `contentType`. Returns `{ fileId, url, fields }`.
        2. **Upload to S3** — `multipart/form-data` `POST` to `url`, all `fields` first then the
           file bytes last as a `file` field. S3 returns `204`.
        3. **Mark complete** — `POST /api/files/upload-complete` with the `fileId`.

        Then, on this request:
        - **Regular attachment** — add the `fileId`(s) to `attachmentIds`.
        - **Inline image** — embed an `<img>` in the HTML `body` whose `src` is the file's
          download URL (`.../cdn/storage/AptlyFiles/<fileId>/original/...`). Auto-detected and
          registered; do **not** also list it in `attachmentIds`.

        Each `fileId` must reference a finished upload.
      operationId: sendEmail
      tags: [Email]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [userId, channelId]
              properties:
                discussionId:
                  type: string
                  description: |
                    Stream ID from `POST /api/email/create-draft` (required if `uuid` is set).
                    When supplied without `uuid`, the on-the-fly draft is appended as a reply
                    into this existing thread (same company + channel) instead of starting a
                    new discussion; the thread's subject is kept.
                uuid:
                  type: string
                  description: Draft UUID from `POST /api/email/create-draft`. Required if `discussionId` is set.
                failOnCreateNewThread:
                  type: boolean
                  default: true
                  description: |
                    If `true` (default), the send fails when the email would create a new
                    discussion thread rather than reply to an existing one.
                userId:
                  type: string
                  description: Required when sending on-the-fly (no `discussionId`/`uuid`).
                channelId:
                  type: string
                  description: Required when sending on-the-fly.
                companyId:
                  type: string
                  description: Company ID. Required only when authenticating with a partner token; resolved automatically for API key and delegate token auth.
                to:
                  type: array
                  items:
                    $ref: "#/components/schemas/EmailRecipient"
                cc:
                  type: array
                  items:
                    $ref: "#/components/schemas/EmailRecipient"
                bcc:
                  type: array
                  items:
                    $ref: "#/components/schemas/EmailRecipient"
                subject:
                  type: string
                body:
                  type: string
                  description: |
                    Email body (plain text or HTML). To embed an uploaded image inline, add
                    an `<img>` whose `src` is the file's download URL from
                    `POST /api/files/upload-complete`
                    (`.../cdn/storage/AptlyFiles/<fileId>/original/...`). Inline images are
                    auto-detected, tagged with `data-inline-image-id`, and registered on the
                    draft — do not also list them in `attachmentIds`. Applies when the draft
                    is created on-the-fly (no `discussionId`/`uuid`).
                attachmentIds:
                  type: array
                  items:
                    type: string
                  description: |
                    File ids to include as regular attachments (applies when the draft is
                    created on-the-fly). Upload each file first (`POST /api/files/upload-url`
                    with attachEntityType `channel` and the same channelId → upload to S3 →
                    `POST /api/files/upload-complete`), then pass the resulting `fileId`s here.
                    Each must reference a finished upload.
                aptletInstanceId:
                  type: string
                  description: |
                    Card to link the outbound to. Logs the email as an activity on the
                    card and tags the discussion. Works whether the draft was pre-created
                    or is created on-the-fly.
            examples:
              from_draft:
                summary: Send from an existing draft
                value:
                  userId: "user_abc"
                  channelId: "channel_abc"
                  discussionId: "stream_abc"
                  uuid: "draft_xyz"
              on_the_fly:
                summary: On-the-fly send
                value:
                  userId: "user_abc"
                  channelId: "channel_abc"
                  to:
                    - value: "tenant@example.com"
                  subject: "Your lease renewal"
                  body: "Please review the attached renewal terms."
                  aptletInstanceId: "card_abc"
      responses:
        "200":
          description: Email sent successfully.
          content:
            application/json:
              schema:
                type: object
        "400":
          description: The request body failed validation, for example a recipient that is not an email address, or a missing `userId` or `channelId`. See `meta.issues` for each field.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ValidationError"
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/app/verify:
    post:
      summary: Verify a delegate token (keyless)
      description: |
        Validates a short-lived delegate token without requiring an API key. The token must
        include an `appClientId` (i.e. it was issued for an embedded app via
        `POST /api/platform/user-token`). Returns the user's identity, company name, and
        the app's title.
      operationId: verifyDelegateToken
      tags: [App]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token:
                  type: string
                  description: Delegate token returned by `POST /api/platform/user-token`.
      responses:
        "200":
          description: Token is valid.
          content:
            application/json:
              schema:
                type: object
                properties:
                  userId:
                    type: string
                  email:
                    type: string
                    nullable: true
                  firstName:
                    type: string
                    nullable: true
                  lastName:
                    type: string
                    nullable: true
                  companyId:
                    type: string
                  companyName:
                    type: string
                    nullable: true
                  appClientId:
                    type: string
                  appTitle:
                    type: string
                    nullable: true
        "400":
          description: token field is missing.
        "401":
          description: Token is invalid, expired, or was not issued for an embedded app (missing appClientId).

  /api/app/me:
    get:
      summary: Get credential info
      description: |
        Returns identity information for the credential used in the request. The response shape
        depends on the auth method:

        - **Delegate token** (`Authorization: DelegateToken <token>`): returns user identity and,
          if the token has an `appClientId`, embedded-app context.
        - **API key** (`x-token` header or query param): returns company identity.
        - **Partner token** (`Authorization: Bearer <token>`): returns the partner's permission list.
      operationId: getMe
      tags: [App]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      responses:
        "200":
          description: Credential identity.
          content:
            application/json:
              schema:
                oneOf:
                  - title: Delegate token — user
                    type: object
                    properties:
                      type:
                        type: string
                        enum: [user]
                      userId:
                        type: string
                      email:
                        type: string
                        nullable: true
                      firstName:
                        type: string
                        nullable: true
                      lastName:
                        type: string
                        nullable: true
                      companyId:
                        type: string
                      companyName:
                        type: string
                        nullable: true
                  - title: Delegate token — app
                    type: object
                    properties:
                      type:
                        type: string
                        enum: [app]
                      userId:
                        type: string
                      email:
                        type: string
                        nullable: true
                      firstName:
                        type: string
                        nullable: true
                      lastName:
                        type: string
                        nullable: true
                      companyId:
                        type: string
                      companyName:
                        type: string
                        nullable: true
                      appClientId:
                        type: string
                      appTitle:
                        type: string
                        nullable: true
                  - title: API key
                    type: object
                    properties:
                      type:
                        type: string
                        enum: [apiKey]
                      companyId:
                        type: string
                      companyName:
                        type: string
                        nullable: true
                  - title: Partner token
                    type: object
                    properties:
                      type:
                        type: string
                        enum: [partner]
                      permissions:
                        type: array
                        items:
                          type: string
        "401":
          description: Invalid or missing credential.

  /api/board/{boardId}/webhook:
    post:
      summary: Set or update the board's webhook
      description: |
        Sets the board's webhook URL. Calling this again re-points an existing webhook (an
        update) rather than creating a second one — a board has at most one webhook.

        A new `webhookSigningKey` is generated every time this is called, including on
        update, so re-pointing a webhook also rotates its signing key.
      operationId: setBoardWebhook
      tags: [Board]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [webhookUrl]
              properties:
                webhookUrl:
                  type: string
                  description: Must be a valid http or https URL.
      responses:
        "201":
          description: Webhook set.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      webhookUrl:
                        type: string
                      webhookSigningKey:
                        type: string
                        description: Shared secret used to sign the webhook payload.
        "400":
          description: Missing or invalid `webhookUrl`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Invalid or missing API key.
        "403":
          description: API access is disabled for this board, or the key lacks insert/update permission.
        "404":
          description: Board not found.
    delete:
      summary: Remove the board's webhook
      description: |
        Removes the board's webhook, if one is set. Idempotent — calling this when no
        webhook is configured still returns 200.
      operationId: deleteBoardWebhook
      tags: [Board]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Webhook removed (or already absent).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      webhookUrl:
                        type: string
                        nullable: true
                        example: null
        "401":
          description: Invalid or missing API key.
        "403":
          description: API access is disabled for this board, or the key lacks update permission.
        "404":
          description: Board not found.

  /api/board/{boardId}/configuration:
    get:
      summary: Get full board configuration
      description: |
        Returns all configuration sections for the board in a single response:
        fields, automations, options, tabViews, workflows, groups, shares, theme, and filters.
      operationId: getBoardConfiguration
      tags: [Board]
      security:
        - ApiKeyHeader: []
        - PartnerBearer: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
          description: The board's UUID.
      responses:
        "200":
          description: Full board configuration.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      fields:
                        type: array
                        items:
                          $ref: "#/components/schemas/BoardField"
                      automations:
                        type: array
                        description: The older create-card rules (same data as `GET .../configuration/card-rules`). Despite the name these are not Automations - those are in `workflows` below, kept under this key for backwards compatibility.
                        items:
                          $ref: "#/components/schemas/CardRule"
                      options:
                        $ref: "#/components/schemas/BoardOptions"
                      tabViews:
                        type: array
                        items:
                          $ref: "#/components/schemas/TabView"
                      workflows:
                        type: array
                        description: The board's Automations (same data as `GET .../configuration/automations`). Called workflows here for backwards compatibility.
                        items:
                          type: object
                      groups:
                        type: array
                        description: Field groups (sections) configured on the board.
                        items:
                          type: object
                      shares:
                        type: object
                        description: Board access settings.
                        properties:
                          accessType:
                            type: string
                            enum: [public, private]
                          acl:
                            type: array
                            items:
                              type: object
                      theme:
                        type: object
                        description: Board identity fields.
                        properties:
                          name:
                            type: string
                          cardName:
                            type: string
                          color:
                            type: string
                            description: Hex color for the board gradient.
                          icon:
                            type: string
                          description:
                            type: string
                          shortCode:
                            type: string
                      filters:
                        type: array
                        description: Saved filters visible to the caller.
                        items:
                          type: object
        "401":
          description: Invalid or missing API key.
        "404":
          description: Board not found.

  /api/board/{boardId}/configuration/automations:
    post:
      summary: Create an automation
      description: |
        Creates an Automation on the board (the board's `workflows`). Requires `update` permission and
        the key's "Automations" access (a separate toggle on the key, off by default, independent of
        "Edit Boards"); a key's board allowlist applies.

        **Always created paused.** Nothing runs until the automation is switched on in the Aptly app,
        so the API can't set one live. Existing cards are not enrolled: once it is switched on a stage
        automation applies to cards that enter the stage afterwards.

        **Validated strictly - the request is refused outright on the first problem.** Unknown
        properties, a stage / field / template / inbox / contact type / user / team that doesn't exist
        (or is archived) on this board or organization, an email template used for an SMS (or the
        reverse), an inbox of the wrong type or with no users, conditions that are empty, on a missing
        field, with an unsupported operator or a bad value, a delay with business hours, or a task or
        due-date action with business days, when none are configured, a webhook to anything but a public https address, and a pair of stage-moving
        automations that would send cards round in a circle are all rejected with `400`. Shape problems
        come back as `VALIDATION_ERROR` with the offending path in `meta.issues`; problems found by
        checking against your data come back as `INVALID_DATA` with a message that names the path
        (for example `actions[1].templateId: template "abc" was not found`).

        Supported triggers: `stage`, `segment` (conditions), `reply`. Supported actions: `email`, `sms`,
        `task`, `assign`, `update_fields`, `stage`, `due_date`, `notify`, `archive`, `archive_convos`,
        `webhook`. Task-completion and field-group triggers and the Yardi, e-sign, PDF, team-access and
        related-card actions aren't available through the API.

        The response is the stored automation (the same shape `GET .../automations` returns).
      operationId: createAutomation
      tags: [Board]
      security:
        - ApiKeyHeader: []
        - PartnerBearer: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
          description: The board's UUID.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateAutomationRequest"
            examples:
              emailAfterStage:
                summary: Email the contact two days after a stage change
                value:
                  title: Tour follow-up
                  trigger: { type: stage, stage: Tour Scheduled }
                  timing: { mode: after, amount: 2, unit: days, timeOfDay: 9 }
                  actions:
                    - type: email
                      inbox: assignee
                      templateId: 5kQx2mP9
                      to: { type: relatedContacts }
              staleLeads:
                summary: Nudge stale leads on Mondays and Wednesdays
                value:
                  title: Nudge stale leads
                  trigger:
                    type: segment
                    conditions:
                      - {
                          fieldUuid: stage,
                          operator: anyIn,
                          value: [New Lead, Contacted],
                        }
                  timing: { mode: daysOfWeek, days: [1, 3], timeOfDay: 8 }
                  actions:
                    - type: update_fields
                      fields:
                        - { fieldUuid: status, value: Needs Follow Up }
                    - type: notify
                      teamIds: [7Hq3dZ]
                      message: Stale lead
      responses:
        "201":
          description: The created automation (paused).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: "The stored automation. `archived: true` means paused."
                    properties:
                      uuid: { type: string }
                      title: { type: string }
                      archived: { type: boolean, example: true }
                      triggerOn: { type: string }
                      timingMode: { type: string }
                      description: { type: string }
                      actions: { type: array, items: { type: object } }
        "400":
          description: "`VALIDATION_ERROR` (shape) or `INVALID_DATA` (a reference or rule that doesn't hold)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ValidationError"
        "401":
          description: Invalid or missing API key.
        "403":
          description: The key lacks `update` permission or the "Automations" access, or the board isn't API-enabled.
        "404":
          description: Board not found.
    get:
      summary: List board automations
      description: |
        Returns the automations configured on a board (the board's `workflows` in the data model).
        The older create-card rules that move or copy cards are listed at `.../configuration/card-rules`.
      operationId: listAutomations
      tags: [Board]
      security:
        - ApiKeyHeader: []
        - PartnerBearer: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
          description: The board's UUID.
      responses:
        "200":
          description: Array of automations.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
        "401":
          description: Invalid or missing API key.
        "404":
          description: Board not found.

  /api/board/{boardId}/configuration/card-rules:
    get:
      summary: List create-card rules
      description: Returns the older create-card rules on a board (`board` and `schedule` rules that move or copy cards). These are not Automations - see `.../configuration/automations`.
      operationId: listCardRules
      tags: [Board]
      security:
        - ApiKeyHeader: []
        - PartnerBearer: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
          description: The board's UUID.
      responses:
        "200":
          description: Array of create-card rules.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/CardRule"
        "401":
          description: Invalid or missing API key.
        "404":
          description: Board not found.

  /api/board/{boardId}/configuration/options:
    get:
      summary: Get board options
      description: Returns the current configuration options for the board.
      operationId: getBoardOptions
      tags: [Board]
      security:
        - ApiKeyHeader: []
        - PartnerBearer: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Board options.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/BoardOptions"
        "401":
          description: Invalid or missing API key.
        "404":
          description: Board not found.

  /api/board/{boardId}/configuration/fields:
    get:
      summary: List board fields
      description: Returns all fields defined on the board, including archived ones.
      operationId: listBoardFields
      tags: [Board]
      security:
        - ApiKeyHeader: []
        - PartnerBearer: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Array of board fields.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/BoardField"
        "401":
          description: Invalid or missing API key.
        "404":
          description: Board not found.
    post:
      summary: Add a field
      description: |
        Adds a custom field to the board. Requires `update` permission and the key's "Edit Boards"
        access (off by default; not needed on a board that same key created); a key's board allowlist
        still applies, except for a board that key created. Accepts the same field shape
        and allowed field types as `POST /api/boards` (relational and system types are rejected;
        use `POST /api/board/{boardId}/configuration/mirror-fields` for a mirror field);
        `uuid` must not be reserved or already used on the board.
      operationId: addBoardField
      tags: [Board]
      security:
        - ApiKeyHeader: []
        - PartnerBearer: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, type]
              properties:
                name:
                  type: string
                type:
                  $ref: "#/components/schemas/NewBoardFieldType"
                uuid:
                  type: string
                  description: Optional key; must match `^[a-zA-Z][a-zA-Z0-9_]{0,63}$`. Generated when omitted.
                optional:
                  type: boolean
                pinned:
                  type: boolean
                startCard:
                  type: boolean
                  description: Show the field on the form when a card is created.
                defaultValue:
                  type: string
                data:
                  type: array
                  description: Required (non-empty) for `singleselect` and `multiselect`.
                  items:
                    type: object
                    required: [label]
                    properties:
                      label:
                        type: string
                      uuid:
                        type: string
            example:
              name: "Priority"
              type: "singleselect"
              data:
                - label: "Low"
                - label: "High"
      responses:
        "201":
          description: The created field.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/BoardField"
        "400":
          description: "`VALIDATION_ERROR` (per-field `meta.issues`) or a rule violation."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ValidationError"
        "401":
          description: Invalid or missing API key.
        "403":
          description: >-
            The key lacks `update` permission or the "Edit Boards" access, the board isn't API-enabled,
            or the field can't be changed (locked, standard, mirror or integration-managed).
        "404":
          description: Board (or field) not found.

  /api/board/{boardId}/configuration/mirror-fields:
    post:
      summary: Add a mirror field
      description: |
        Adds a field to the board that mirrors a field of another board. The new field takes the
        target field's type (and, for select types, a copy of its option list), is named
        `Mirror <target field name>` unless you supply `name`, and is read-only unless you set
        `readOnly: false`.

        This only adds the field - the same as the Aptly app. A card shows a mirrored value once
        it is linked to a card on the target board (for example through a related-cards field) and
        is next saved; existing cards are not backfilled when the field is added.

        Requires `update` permission and the key's "Edit Boards" access (off by default). A key's
        board allowlist applies to **both** boards: the target board must be one the key can access
        and must be API-enabled. The target field must be one of `string`, `text`, `rich-text`,
        `address`, `email`, `url`, `datetime-local`, `date`, `number`, `percent`, `stageselect`,
        `singleselect`, `multiselect`, `tags`, `boolean`, `person`, `persons`, `relatedAptlets`,
        `files`, `file`, `money`, `user` or `lockAlias`, and can't itself be a mirror field. Mirroring
        the same target field twice on one board returns `409`.
      operationId: addBoardMirrorField
      tags: [Board]
      security:
        - ApiKeyHeader: []
        - PartnerBearer: []
      parameters:
        - name: boardId
          in: path
          required: true
          description: The board that gets the mirror field.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [targetBoardUuid, targetFieldUuid]
              properties:
                targetBoardUuid:
                  type: string
                  description: The uuid of the board to mirror a field from.
                targetFieldUuid:
                  type: string
                  description: The uuid of the field on that board to mirror.
                name:
                  type: string
                  description: Display name for the new field. Defaults to `Mirror <target field name>`.
                readOnly:
                  type: boolean
                  description: Whether users may edit the mirrored value on this board. Defaults to `true`.
            example:
              targetBoardUuid: "deals"
              targetFieldUuid: "rent"
              name: "Deal rent"
      responses:
        "201":
          description: The created field.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/BoardField"
              example:
                data:
                  uuid: "Qk3x9TnWc2LmPa8vR"
                  name: "Deal rent"
                  type: "money"
                  optional: true
                  pinned: false
                  position: 12
                  readOnly: true
                  metricField: false
                  mirrorTarget:
                    targetAptletUuid: "deals"
                    targetFieldUuid: "rent"
        "400":
          description: >-
            `VALIDATION_ERROR` (per-field `meta.issues`), or the target field's type can't be
            mirrored, or the target field is itself a mirror field.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ValidationError"
        "401":
          description: Invalid or missing API key.
        "403":
          description: >-
            The key lacks `update` permission or the "Edit Boards" access, either board is outside
            the key's board allowlist, or the board or target board isn't API-enabled.
        "404":
          description: The board, the target board or the target field was not found.
        "409":
          description: >-
            The board already mirrors that target field. `meta.fieldUuid` is the existing mirror field.

  /api/board/{boardId}/configuration/fields/{fieldUuid}:
    put:
      summary: Update a field
      description: |
        Updates a field's `name`, `optional`, `pinned`, `startCard`, `defaultValue` and - for select-type fields -
        its full `data` option list. A field's `type` and `uuid` never change. Options re-sent by label
        keep their uuid. Refused (403) for locked fields - except `startCard`, which any field accepts - and the options of integration-managed
        fields. Requires `update` permission and "Edit Boards" access (not needed on a board that
        same key created).

        Renaming a field also rewrites its `Board["Field"]` merge tokens in the company's templates
        (best-effort: a failure there never fails the update).

        **Editing the stage pipeline** (`fieldUuid` = `stage`): send the full `data` list in the order
        you want. You can add stages, reorder them and recolor them. Existing stages are matched by
        `uuid` (or by `label` when you leave `uuid` out) and keep their `uuid`. A stage that isn't
        matched is added: it gets a new `uuid` and may set `color`, `closed` and `won` (`won` is for
        deal boards only and implies `closed`). Cards, automations and saved filters refer to stages
        by name, so these are refused with 403: leaving an existing stage out (removing it), changing
        an existing stage's `label` (renaming it) and changing an existing stage between open,
        closed and won. The first stage is where new cards start, so whichever stage ends up first
        must be open (unless it was already first). Labels must be unique and there can be at most 30
        stages. Board views that are open in the Aptly app refresh to show the change.
      operationId: updateBoardField
      tags: [Board]
      security:
        - ApiKeyHeader: []
        - PartnerBearer: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
        - name: fieldUuid
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                optional:
                  type: boolean
                pinned:
                  type: boolean
                startCard:
                  type: boolean
                  description: Show the field on the form when a card is created.
                defaultValue:
                  type: string
                data:
                  type: array
                  items:
                    type: object
                    required: [label]
                    properties:
                      label:
                        type: string
                      uuid:
                        type: string
                      color:
                        type: string
                        example: "#1ec968"
                        description: Stage field only. Hex color.
                      closed:
                        type: boolean
                        description: >-
                          Stage field only. For an existing stage it must match the stage as it is;
                          for a new stage it makes it a closed stage.
                      won:
                        type: boolean
                        description: >-
                          Stage field only. For an existing stage it must match the stage as it is;
                          for a new stage on a deal board it makes it a won stage.
      responses:
        "200":
          description: The updated field.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/BoardField"
        "400":
          description: "`VALIDATION_ERROR` (per-field `meta.issues`) or a rule violation."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ValidationError"
        "401":
          description: Invalid or missing API key.
        "403":
          description: >-
            The key lacks `update` permission or the "Edit Boards" access, the board isn't API-enabled,
            or the field can't be changed (locked, standard, mirror or integration-managed).
        "404":
          description: Board (or field) not found.
    delete:
      summary: Delete a field
      description: |
        Removes a field from the board (card values already stored for it are left in place). Refused
        for standard fields (`name`, `stage`, `description`, `tags`, `checklist`, `files`,
        `lastAction`, `dueAt`, `relatedContacts`), locked, mirror and integration-managed fields (403),
        and - as in the Aptly app - for a field used by a saved filter, or by a workflow or automation
        that runs off one (409 `CONFLICT`, with the dependents in `meta`). Requires `update` permission
        and "Edit Boards" access (not needed on a board that same key created).
      operationId: deleteBoardField
      tags: [Board]
      security:
        - ApiKeyHeader: []
        - PartnerBearer: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
        - name: fieldUuid
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Field deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      uuid:
                        type: string
                      deleted:
                        type: boolean
        "401":
          description: Invalid or missing API key.
        "403":
          description: Missing permission or "Edit Boards" access, or the field can't be deleted.
        "404":
          description: Board or field not found.
        "409":
          description: The field is used by saved filters, workflows or automations.

  /api/board/{boardId}/configuration/field-order:
    put:
      summary: Reorder fields
      description: |
        Sets the order of a board's fields. Send the field `uuid`s in the order you want in `fields`.
        The listed fields swap among the positions they already hold, so fields you leave out, and the
        board's field groups, stay where they are. List every field to reorder the whole board.
        Sending the current order changes nothing. Locked fields can be moved too. Refused (400) for an
        unknown or repeated uuid. Returns every field of the board in its new order.
        Requires `update` permission and "Edit Boards" access (not needed on a board that same key
        created).
      operationId: reorderBoardFields
      tags: [Board]
      security:
        - ApiKeyHeader: []
        - PartnerBearer: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fields]
              properties:
                fields:
                  type: array
                  minItems: 2
                  maxItems: 500
                  items:
                    type: string
                  description: Field uuids in the order you want them.
                  example: ["dueAt", "name", "priority"]
      responses:
        "200":
          description: The board's fields in their new order.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        uuid:
                          type: string
                        name:
                          type: string
                        position:
                          type: integer
        "400":
          description: "`VALIDATION_ERROR`, or a uuid that isn't on the board or is listed twice."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ValidationError"
        "401":
          description: Invalid or missing API key.
        "403":
          description: >-
            The key lacks `update` permission or the "Edit Boards" access, or the board isn't API-enabled.
        "404":
          description: Board not found.

  /api/board/{boardId}/configuration/groups:
    get:
      summary: List board field groups
      description: Returns the field groups (sections) configured on the board.
      operationId: listGroups
      tags: [Board]
      security:
        - ApiKeyHeader: []
        - PartnerBearer: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Array of groups.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
        "401":
          description: Invalid or missing API key.
        "404":
          description: Board not found.
    post:
      summary: Create a field group
      description: |
        Adds a field group (section) to the board. Only `name` is required. `fieldUuids` lists the board's
        fields to put in the group, in order; each must be a field on the board and may appear once.
        `required` and `pinned` default to false. Task groups, auto-assignment and subgroups can't be set
        here. Requires `update` permission and "Edit Boards" access (not needed on a board that same key
        created).
      operationId: createBoardGroup
      tags: [Board]
      security:
        - ApiKeyHeader: []
        - PartnerBearer: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  maxLength: 100
                description:
                  type: string
                  maxLength: 500
                fieldUuids:
                  type: array
                  maxItems: 500
                  items:
                    type: string
                  description: Uuids of fields on the board, in the order they should appear in the group.
                required:
                  type: boolean
                pinned:
                  type: boolean
      responses:
        "201":
          description: The created group.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
        "400":
          description: "`VALIDATION_ERROR`, or a field uuid that isn't on the board or is listed twice."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ValidationError"
        "401":
          description: Invalid or missing API key.
        "403":
          description: The key lacks `update` permission or the "Edit Boards" access, or the board isn't API-enabled.
        "404":
          description: Board not found.

  /api/board/{boardId}/configuration/groups/{groupUuid}:
    put:
      summary: Update a field group
      description: |
        Changes a group's `name`, `description` (an empty string clears it), `fieldUuids`, `required` or
        `pinned`. Only the properties you send change; the group's subgroups and auto-assignment are left
        as they are. `fieldUuids` replaces the group's field list, so send the full list in the order you
        want. Requires `update` permission and "Edit Boards" access (not needed on a board that same key
        created).
      operationId: updateBoardGroup
      tags: [Board]
      security:
        - ApiKeyHeader: []
        - PartnerBearer: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
        - name: groupUuid
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                name:
                  type: string
                  maxLength: 100
                description:
                  type: string
                  maxLength: 500
                fieldUuids:
                  type: array
                  maxItems: 500
                  items:
                    type: string
                  description: Uuids of fields on the board, in the order they should appear in the group.
                required:
                  type: boolean
                pinned:
                  type: boolean
      responses:
        "200":
          description: The updated group.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
        "400":
          description: "`VALIDATION_ERROR`, or a field uuid that isn't on the board or is listed twice."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ValidationError"
        "401":
          description: Invalid or missing API key.
        "403":
          description: The key lacks `update` permission or the "Edit Boards" access, or the board isn't API-enabled.
        "404":
          description: Board or group not found.

  /api/board/{boardId}/configuration/start-card-order:
    get:
      summary: Get the create-card form order
      description: |
        Returns the field and group uuids in the order the create-card form shows them. Items that are
        not listed sort after these, in the board's usual order. An empty list means the default order.
        Which fields appear on the form at all is each field's `startCard` flag.
      operationId: getStartCardOrder
      tags: [Board]
      security:
        - ApiKeyHeader: []
        - PartnerBearer: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Field and group uuids in order.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: string
        "401":
          description: Invalid or missing API key.
        "404":
          description: Board not found.
    put:
      summary: Set the create-card form order
      description: |
        Replaces the order of the create-card form with `order`: field and group uuids, first to last.
        Every uuid must be a field or group on the board and may appear once (400 otherwise). Items you
        leave out sort after the listed ones, and an empty list restores the default order. This only
        orders the form; use each field's `startCard` flag to choose which fields appear. Requires
        `update` permission and "Edit Boards" access (not needed on a board that same key created).
      operationId: setStartCardOrder
      tags: [Board]
      security:
        - ApiKeyHeader: []
        - PartnerBearer: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [order]
              properties:
                order:
                  type: array
                  maxItems: 500
                  items:
                    type: string
                  description: Field and group uuids in the order you want them.
                  example: ["tenantName", "contactGroup", "dueAt"]
      responses:
        "200":
          description: The saved order.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: string
        "400":
          description: "`VALIDATION_ERROR`, or a uuid that isn't a field or group on the board or is listed twice."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ValidationError"
        "401":
          description: Invalid or missing API key.
        "403":
          description: The key lacks `update` permission or the "Edit Boards" access, or the board isn't API-enabled.
        "404":
          description: Board not found.

  /api/board/{boardId}/configuration/shares:
    get:
      summary: Get board access settings
      description: Returns the board's access type and ACL (shares array).
      operationId: getBoardShares
      tags: [Board]
      security:
        - ApiKeyHeader: []
        - PartnerBearer: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Board access settings.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      accessType:
                        type: string
                        enum: [public, private]
                      acl:
                        type: array
                        items:
                          type: object
                          properties:
                            _id:
                              type: string
                            userId:
                              type: string
                            teamId:
                              type: string
                            permissions:
                              type: array
                              items:
                                type: string
        "401":
          description: Invalid or missing API key.
        "404":
          description: Board not found.

  /api/board/{boardId}/configuration/theme:
    get:
      summary: Get board identity fields
      description: Returns the board's display name, color, icon, description, and short code.
      operationId: getBoardTheme
      tags: [Board]
      security:
        - ApiKeyHeader: []
        - PartnerBearer: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Board identity fields.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      name:
                        type: string
                      cardName:
                        type: string
                      color:
                        type: string
                        description: Hex color for the board gradient.
                      icon:
                        type: string
                      description:
                        type: string
                      shortCode:
                        type: string
                        description: Up to 5-character uppercase alphanumeric code.
        "401":
          description: Invalid or missing API key.
        "404":
          description: Board not found.
    patch:
      summary: Update board identity fields
      description: |
        Updates any of the board's `name`, `cardName`, `color`, `icon`, `description` and `shortCode`;
        at least one is required. `color` must be a hex color, `icon` a Font Awesome class string, and
        `shortCode` 1-5 letters or numbers (stored uppercase). Requires `update` permission and the
        key's "Edit Boards" access (not needed on a board that same key created).
      operationId: updateBoardTheme
      tags: [Board]
      security:
        - ApiKeyHeader: []
        - PartnerBearer: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                cardName:
                  type: string
                color:
                  type: string
                  example: "#1ec968"
                icon:
                  type: string
                  example: "fas fa-wrench"
                description:
                  type: string
                shortCode:
                  type: string
      responses:
        "200":
          description: The board's identity fields after the update.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      name:
                        type: string
                      cardName:
                        type: string
                      color:
                        type: string
                      icon:
                        type: string
                      description:
                        type: string
                      shortCode:
                        type: string
        "400":
          description: "`VALIDATION_ERROR` (per-field `meta.issues`) or a rule violation."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ValidationError"
        "401":
          description: Invalid or missing API key.
        "403":
          description: >-
            The key lacks `update` permission or the "Edit Boards" access, the board isn't API-enabled,
            or the field can't be changed (locked, standard, mirror or integration-managed).
        "404":
          description: Board (or field) not found.

  /api/board/{boardId}/configuration/filters:
    get:
      summary: List board filters
      description: >-
        Read-only saved segments for this board. Public means company-shared; private means owned by the
        resolved user. Default visibility=all returns public plus that user’s private segments, never other
        users’ private segments. Archived segments and quick views are excluded. Results include the original
        rules and a public/private visibility. Name matching is a case-insensitive literal substring.


        Supports API keys, delegate read tokens, and partner bearer tokens with board-admin; mobile auth is not
        accepted. API keys/partners may provide userId (or x-user-id) for an active member of the board’s
        company. Delegates use their token user and cannot select someone else. Without a userId only public
        segments are returned; visibility=private requires a userId. Keys/delegates require API-enabled boards.
        Partner tokens retain the existing API-enabled bypass.
      operationId: getBoardFilters
      tags:
        - Board
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: boardId
          in: path
          required: true
          schema:
            type: string
        - name: name
          in: query
          schema:
            type: string
            maxLength: 200
          description: Case-insensitive literal substring of segment name.
        - name: visibility
          in: query
          schema:
            type: string
            enum:
              - public
              - private
              - all
            default: all
        - name: userId
          schema:
            type: string
            maxLength: 256
          description:
            Active company member whose private segments/currentUser values to use. API keys/partners may
            select a member; delegates cannot select another user.
          in: query
      responses:
        "200":
          description: List of filters.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        _id:
                          type: string
                        name:
                          type: string
                        scope:
                          type: string
                          enum:
                            - company
                            - user
                        aptletUuid:
                          type: string
                        rules:
                          type: array
                          items:
                            $ref: "#/components/schemas/BoardFilterRule"
                        archived:
                          type: boolean
                        visibility:
                          type: string
                          enum:
                            - public
                            - private
        "400":
          description: Invalid rules, query parameters, or missing user context.
        "401":
          description: Invalid or missing API key.
        "403":
          description: Read access denied, API disabled, company mismatch, or invalid user selection.
        "404":
          description: Board not found.

  /api/templates:
    get:
      summary: List templates
      description: |
        Returns communication templates for the company. All filter parameters are optional.

        Accepts an API key (`x-token`), delegate token (`Authorization: DelegateToken <token>`),
        or partner token (`Authorization: Bearer <token>` with `templates` permission).
        When using a partner token, pass `companyId` as a query parameter.
      operationId: listTemplates
      tags: [Templates]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: companyId
          in: query
          schema:
            type: string
          description: Company ID. Required when using a partner token; resolved automatically for API key and delegate token auth.
        - name: templateType
          in: query
          schema:
            type: string
            enum: [sms, email, form, eSignature, pdf, blockDocument]
          description: Filter by template type.
        - name: aptletUuid
          in: query
          schema:
            type: string
          description: Filter by associated board UUID.
        - name: archived
          in: query
          schema:
            type: boolean
          description: Filter by archived status.
      responses:
        "200":
          description: Array of templates.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Template"
        "400":
          description: Missing companyId.
        "401":
          description: Invalid or missing credential.

    post:
      summary: Create a template
      description: |
        Creates a communication template.

        The acting user — the delegate token's own user, or the body `userId` when
        authenticating with an API key or partner token — must belong to the company and
        hold the `setup` or `setup_communication_templates` permission.

        Only plain templates can be created here — `htmlBuilder` must be `false` or
        omitted.

        `attachmentIds` are resolved against the company's uploaded files, stored on the
        template as full attachment entries, and tied to it so the detached-upload cleanup
        leaves them alone — ids that no longer resolve are dropped. A `pdf` template's
        `subject` becomes a file name, so characters that are illegal in one are stripped.

        ### Merge fields

        `subject` and `content` may contain merge placeholders, which are replaced with
        the recipient's, board card's or company's own values when the template is sent.

        Fetch the available fields from
        `GET /api/board/{boardId}/merge-fields` for the board the template is used with.
        Every item in that response is `{ label, value }` — `label` is what to show a
        human, `value` is what goes inside the braces:

        ```
        {{firstname}}
        {{Locations["Rent"]}}
        {{customFields.7Lq2.LoyaltyTier}}
        ```

        A placeholder may carry a fallback, used when the field is empty or unknown:

        ```
        {{firstname || fallback: there}}        ->  "there" when the contact has no first name
        {{Locations["Rent"] || fallback: TBD}}  ->  "TBD" when the card has no rent
        {{firstname || fallback: }}             ->  renders nothing at all
        ```

        Everything after `fallback:` up to the closing braces is the fallback text, and an
        empty one (or `_blank`) renders nothing.

        Placeholders are **shape**-checked on write — an unclosed `{{`, an empty field
        name, or a malformed bracket reference such as `Locations["Rent]` is rejected with
        400. Whether the field exists is not checked: an unknown field is normal (boards
        differ), and simply renders as its fallback at merge time.
      operationId: createTemplate
      tags: [Templates]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TemplateUpsert"
            examples:
              email:
                summary: Plain email template
                value:
                  companyId: "{{companyId}}"
                  userId: "{{userId}}"
                  name: "Move-in welcome"
                  description: "Sent to new residents on move-in day"
                  templateType: email
                  subject: "Welcome to your new home"
                  content: "<p>Welcome!</p>"
                  htmlBuilder: false
      responses:
        "201":
          description: Template created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/Template"
        "400":
          description: Missing companyId, or an invalid payload.
        "401":
          description: Invalid or missing credential.
        "403":
          description: The acting user lacks the template permission.

  /api/templates/{id}:
    get:
      summary: Get a template
      description: Returns a single template by its ID. The template must belong to the authenticated company.
      operationId: getTemplate
      tags: [Templates]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: Template ID.
        - name: companyId
          in: query
          schema:
            type: string
          description: Required for partner token auth.
      responses:
        "200":
          description: Template record.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/Template"
        "401":
          description: Invalid or missing credential.
        "404":
          description: Template not found.

    put:
      summary: Update a template
      description: |
        Replaces the fields of an existing template. The payload and its validation are
        identical to `POST /api/templates` — including the merge-field placeholders
        documented there, whose available `value`s come from
        `GET /api/board/{boardId}/merge-fields`. On top of the template permission, the
        acting user must have access to the library folder the template sits in. A
        template that does not exist — or belongs to another company — returns 404; an
        archived one returns 403, since it is the caller's own and
        `GET /api/templates/{id}` still returns it.
      operationId: editTemplate
      tags: [Templates]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: Template ID.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TemplateUpsert"
            examples:
              email:
                summary: Plain email template
                value:
                  companyId: "{{companyId}}"
                  userId: "{{userId}}"
                  name: "Move-in welcome"
                  description: "Sent to new residents on move-in day"
                  templateType: email
                  subject: "Welcome to your new home"
                  content: "<p>Welcome!</p>"
                  htmlBuilder: false
      responses:
        "200":
          description: Template updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/Template"
        "400":
          description: Missing companyId, or an invalid payload.
        "401":
          description: Invalid or missing credential.
        "403":
          description: The acting user lacks the template permission or folder access.
        "404":
          description: Template not found.

  /api/tasks/search:
    post:
      summary: Search tasks
      description: |
        Query tasks for the authenticated company. All body fields are optional filters.
        Date-range filters (`dueAt`, `checkedAt`, `updatedAt`) take an object of
        `{ startDate, endDate }` — either bound may be supplied independently. Set
        `useCount: true` to return `{ count }` instead of `{ tasks }`.
      operationId: searchTasks
      tags: [Tasks]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                userIds:
                  type: array
                  items:
                    type: string
                  description: Restrict to tasks assigned to these user ids.
                isChecked:
                  type: boolean
                  description: Filter by completion state.
                isPinned:
                  type: boolean
                archived:
                  type: boolean
                  default: false
                taskPriority:
                  type: string
                  description: >-
                    One of asap|high|medium|low. The search filter is named `taskPriority`; the
                    field is `priority` on the task itself and in update bodies.
                status:
                  oneOf:
                    - type: string
                    - type: array
                      items:
                        type: string
                  description: One status or a list of statuses (backlog|notStarted|inProgress|onHold|completed).
                streamId:
                  type: string
                channelId:
                  type: string
                aptletUuid:
                  type: string
                  description: Board UUID.
                aptletInstanceId:
                  type: string
                leaderboard:
                  type: boolean
                onlyAssigned:
                  type: boolean
                unAssigned:
                  type: boolean
                useCount:
                  type: boolean
                  description: Return a count instead of the task list.
                limit:
                  type: integer
                  default: 1000
                dueAt:
                  $ref: "#/components/schemas/TaskDateRange"
                checkedAt:
                  $ref: "#/components/schemas/TaskDateRange"
                updatedAt:
                  $ref: "#/components/schemas/TaskDateRange"
            example:
              isChecked: false
              archived: false
              taskPriority: high
              dueAt:
                startDate: "2026-06-01T00:00:00.000Z"
                endDate: "2026-06-30T00:00:00.000Z"
              limit: 100
      responses:
        "200":
          description: Matching tasks (or a count).
          content:
            application/json:
              schema:
                type: object
                properties:
                  tasks:
                    type: array
                    items:
                      $ref: "#/components/schemas/Task"
                  count:
                    type: integer
                    description: Present instead of `tasks` when `useCount` is true.
        "400":
          description: Invalid filter value.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Invalid or missing credential.

  /api/tasks:
    post:
      summary: Create a task
      description: |
        Creates a task. When `aptletInstanceId` is set, the task is also mirrored as a
        checklist entry on that card.

        The task is attributed to an acting user who must belong to the company. With a
        delegate token the user is taken from the token. With an API key (no associated
        user), supply `userId` in the body — it must belong to the company or the
        request is rejected.
      operationId: createTask
      tags: [Tasks]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title, assigneeId, dueAt, type]
              properties:
                userId:
                  type: string
                  description: |
                    Acting user the task is attributed to (createdBy/updatedBy).
                    Required with API-key auth (must belong to the company); ignored
                    with a delegate token, which supplies the user itself.
                title:
                  type: string
                assigneeId:
                  type: string
                  description: User id the task is assigned to.
                dueAt:
                  type: string
                  format: date-time
                type:
                  type: string
                  description: Follow-up type — one of email|comment|sms|voice|card|note|task|logActivity.
                note:
                  type: string
                  description: Task description.
                status:
                  type: string
                  description: One of backlog|notStarted|inProgress|onHold|completed.
                priority:
                  type: string
                  description: One of asap|high|medium|low.
                logType:
                  type: string
                  description: Required when type is "logActivity".
                aptletInstanceId:
                  type: string
                  description: >-
                    Card id — when set the task is mirrored onto the card checklist, and its
                    `aptletUuid` is taken from the card (a supplied one is ignored). 404 if the
                    card doesn't exist.
                aptletUuid:
                  type: string
                  description: Board UUID. Taken from the card instead when `aptletInstanceId` is set.
                channelId:
                  type: string
                streamId:
                  type: string
                fieldId:
                  type: string
                  description: Checklist array field on the card (defaults to "checklist").
                attachmentIds:
                  type: array
                  items:
                    type: string
                references:
                  type: array
                  items:
                    type: object
            example:
              title: Call the tenant
              assigneeId: usr_123
              dueAt: "2026-06-10T00:00:00.000Z"
              type: task
              priority: high
              status: notStarted
      responses:
        "200":
          description: Task created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  taskId:
                    type: string
        "400":
          description: Missing or invalid field.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Invalid or missing credential.

  /api/tasks/bulk-update:
    post:
      summary: Update many tasks
      description: |
        Applies one update to up to 100 tasks: either an explicit `taskIds` list (archived
        tasks included, so they can be un-archived), or every non-archived task on a card
        (`aptletInstanceId`) — open tasks only unless `includeCompleted` is true. Each task is updated exactly as `PUT /api/tasks/{taskId}` would update it
        (card checklist, assignment notifications and completion workflows included).

        Use `archived: true` to close or skip tasks, `checked: true` to complete them.
        One task failing does not stop the others; it is listed in `failed`. A task on a
        board outside an API key's board allowlist is reported as `FORBIDDEN`.
      operationId: bulkUpdateTasks
      tags: [Tasks]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [update]
              properties:
                userId:
                  type: string
                  description: |
                    Acting user the updates are attributed to. Required with API-key auth;
                    ignored with a delegate token, which supplies the user itself.
                taskIds:
                  type: array
                  maxItems: 100
                  items:
                    type: string
                  description: Tasks to update. Provide this or `aptletInstanceId`.
                aptletInstanceId:
                  type: string
                  description: Update every task on this card instead of a `taskIds` list.
                includeCompleted:
                  type: boolean
                  default: false
                  description: With `aptletInstanceId`, also update completed tasks.
                update:
                  type: object
                  additionalProperties: false
                  minProperties: 1
                  properties:
                    status:
                      type: string
                      enum: [backlog, notStarted, inProgress, onHold, completed]
                    priority:
                      type: string
                      enum: [asap, high, medium, low]
                    dueAt:
                      type: string
                      format: date-time
                      nullable: true
                    assigneeId:
                      type: string
                    checked:
                      type: boolean
                    archived:
                      type: boolean
            example:
              aptletInstanceId: "a1b2c3d4e5f6"
              update:
                archived: true
      responses:
        "200":
          description: Which tasks were updated and which were not.
          content:
            application/json:
              schema:
                type: object
                properties:
                  updated:
                    type: array
                    items:
                      type: string
                  failed:
                    type: array
                    items:
                      type: object
                      properties:
                        taskId:
                          type: string
                        code:
                          type: string
                          example: FORBIDDEN
                        message:
                          type: string
        "400":
          description: Invalid body, no target given, or more than 100 matching tasks.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Invalid or missing credential.

  /api/tasks/{taskId}:
    get:
      summary: Get a task by ID
      description: |
        Fetches a single task with related card/board context and resolved attachment
        metadata. When `includeMetadata=true`, also returns display labels
        (`priorityLabel`, `statusLabel`) and the resolved `assignee`.
      operationId: getTask
      tags: [Tasks]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      parameters:
        - name: taskId
          in: path
          required: true
          schema:
            type: string
        - name: includeMetadata
          in: query
          required: false
          schema:
            type: boolean
          description: Include display labels and the resolved assignee.
      responses:
        "200":
          description: The task.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Task"
        "401":
          description: Invalid or missing credential.
        "404":
          description: Task not found.
    put:
      summary: Update a task
      description: |
        Updates a task and keeps its card-checklist mirror entry in sync.

        The update is attributed to a user who must belong to the company. With a
        delegate token the user is taken from the token. With an API key (no
        associated user), supply `userId` in the body — it must belong to the
        company or the request is rejected.
      operationId: updateTask
      tags: [Tasks]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      parameters:
        - name: taskId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                userId:
                  type: string
                  description: |
                    Acting user the update is attributed to. Required with API-key
                    auth (must belong to the company); ignored with a delegate
                    token, which supplies the user itself.
                title:
                  type: string
                description:
                  type: string
                checked:
                  type: boolean
                dueAt:
                  type: string
                  format: date-time
                status:
                  type: string
                  description: One of backlog|notStarted|inProgress|onHold|completed.
                priority:
                  type: string
                  description: One of asap|high|medium|low.
                assigneeId:
                  type: string
                archived:
                  type: boolean
                archivedAt:
                  type: string
                  format: date-time
                startedAt:
                  type: string
                  format: date-time
                checkedAt:
                  type: string
                  format: date-time
                pinned:
                  type: boolean
                rank:
                  type: number
                attachmentIds:
                  type: array
                  items:
                    type: string
                outcomeType:
                  type: string
                aptletInstanceId:
                  type: string
                fieldId:
                  type: string
            example:
              title: Renamed task
              checked: true
              status: completed
              priority: high
      responses:
        "200":
          description: Task updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        "400":
          description: Missing or invalid field.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Invalid or missing credential.
        "404":
          description: Task not found.

  /api/routing-groups:
    get:
      summary: List routing groups
      description: Returns all active routing groups for the authenticated company.
      operationId: listRoutingGroups
      tags: [RoutingGroups]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      responses:
        "200":
          description: Array of routing groups.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    _id:
                      type: string
                    name:
                      type: string
                    type:
                      type: string
                      enum: [simultaneous, sequential]
                    destination:
                      type: object
                    ringDurationSeconds:
                      type: number
                    callerExperience:
                      type: object
                    maxWaitSeconds:
                      type: number
                    overflow:
                      type: object
                    archived:
                      type: boolean
        "401":
          description: Invalid or missing credential.

  /api/routing-groups/create:
    post:
      summary: Create a routing group
      description: Creates a new routing group for the authenticated company. Returns the new group's ID.
      operationId: createRoutingGroup
      tags: [RoutingGroups]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, type]
              properties:
                name:
                  type: string
                  description: Display name for the routing group.
                type:
                  type: string
                  enum: [simultaneous, sequential]
                  description: Ring mode. `simultaneous` rings all members at once; `sequential` tries each in order.
                destination:
                  type: object
                  description: Ring target configuration. Supports `ringType` of `user`, `phone`, `browser`, `agent`, or `multi`.
                ringDurationSeconds:
                  type: number
                  description: How long (in seconds) to ring each target before moving on. Defaults to 20.
                callerExperience:
                  type: object
                  description: Caller-side experience config. Set `mode` to `ring` (default) or `hold-music`.
                maxWaitSeconds:
                  type: number
                  description: Maximum time (in seconds) a caller will wait before overflow handling triggers.
                overflow:
                  type: object
                  description: Overflow/voicemail config. Supports `greetingText` and `voicemailNotification` (array of emails).
            example:
              name: "Support Queue"
              type: "simultaneous"
              destination:
                ringType: "multi"
                ringToList: []
              ringDurationSeconds: 20
              callerExperience:
                mode: "ring"
      responses:
        "200":
          description: Routing group created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  _id:
                    type: string
                    description: ID of the created routing group.
        "400":
          description: Missing or invalid field (`name` required; `type` must be simultaneous or sequential).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Invalid or missing credential.

  /api/routing-groups/{id}:
    put:
      summary: Update a routing group
      description: |
        Updates an existing routing group. All fields are optional — only provided fields are changed.
        Pass `null` for a field to clear it.
      operationId: updateRoutingGroup
      tags: [RoutingGroups]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: The routing group ID.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                type:
                  type: string
                  enum: [simultaneous, sequential]
                destination:
                  type: object
                  description: Ring target configuration.
                ringDurationSeconds:
                  type: number
                callerExperience:
                  type: object
                maxWaitSeconds:
                  type: number
                overflow:
                  type: object
            example:
              name: "Support Queue"
              ringDurationSeconds: 30
              overflow:
                greetingText: "Please leave a message."
                voicemailNotification: ["support@example.com"]
      responses:
        "200":
          description: Routing group updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        "400":
          description: Invalid field value (e.g. unrecognized `type`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Invalid or missing credential.
        "404":
          description: Routing group not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/routing-groups/{id}/archive:
    post:
      summary: Archive a routing group
      description: Soft-deletes a routing group. Archived groups are excluded from list results and cannot be updated.
      operationId: archiveRoutingGroup
      tags: [RoutingGroups]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: The routing group ID.
      responses:
        "200":
          description: Routing group archived.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        "401":
          description: Invalid or missing credential.
        "404":
          description: Routing group not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/files/upload-url:
    post:
      summary: Get a presigned URL for a direct file upload
      description: |
        First step of the direct file-upload flow. Issues a presigned S3 POST
        policy and records a pending upload.

        **Uploading a file takes three steps:**

        1. **Get a URL** — call this endpoint. It returns `{ fileId, url, fields }`.
        2. **Upload directly to S3** — send a `multipart/form-data` `POST` to
           `url`. Include **every** key/value from `fields` as form fields first,
           then the file itself as the last field named `file`. The upload goes
           straight to S3; it does not pass through this API. A successful upload
           returns HTTP `204` from S3.
        3. **Mark the upload complete** — call
           `POST /api/files/upload-complete` with the `fileId` from step 1. This
           records the file and returns its download URL.

        The presigned policy enforces the content type and a 1 byte–50 MB size
        range. Both `extension` and `contentType` must be on the accepted lists.

        `attachEntityType` is `channel`, `aptlet`, `knowledge`, or `template`, and
        `attachEntityId` is the channel id, aptlet uuid, knowledge doc id, or
        communication template id respectively. The target must exist (scoped to your
        company) or the request is rejected. The file is stored under that entity's
        folder.
      operationId: getFileUploadUrl
      tags: [Files]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                [
                  name,
                  extension,
                  size,
                  contentType,
                  attachEntityType,
                  attachEntityId,
                ]
              properties:
                name:
                  type: string
                  description: Original file name.
                  example: invoice.pdf
                extension:
                  type: string
                  description: File extension (lowercase, no dot). Must be an accepted extension.
                  example: pdf
                size:
                  type: integer
                  description: File size in bytes. Must be 1–52428800 (50 MB).
                  example: 20480
                contentType:
                  type: string
                  description: MIME type. Must be an accepted content type.
                  example: application/pdf
                attachEntityType:
                  type: string
                  enum: [channel, aptlet, knowledge, template]
                  description: The kind of entity the file is attached to.
                  example: channel
                attachEntityId:
                  type: string
                  description: >-
                    The channel id (`channel`), aptlet uuid (`aptlet`), knowledge doc id
                    (`knowledge`), or communication template id (`template`) the file
                    belongs to. Must exist within your company.
                  example: 7f3c2a1b9d4e5f6a7b8c9d0e
      responses:
        "200":
          description: Presigned upload policy.
          content:
            application/json:
              schema:
                type: object
                properties:
                  fileId:
                    type: string
                    description: ID to pass to `/api/files/upload-complete`.
                  url:
                    type: string
                    description: S3 endpoint to POST the multipart form to.
                  fields:
                    type: object
                    additionalProperties:
                      type: string
                    description: Form fields that must be included in the multipart POST (the file part goes last).
        "400":
          description: Invalid or unsupported file, or missing required field.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Invalid or missing credential.
        "404":
          description: Target channel, aptlet, or knowledge doc not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/files/upload-complete:
    post:
      summary: Finalize a direct file upload
      description: |
        Final (third) step of the direct file-upload flow. Confirms the file
        was uploaded to S3 and records it. Call this with the `fileId` from
        `/api/files/upload-url` after the `multipart/form-data` POST to the
        presigned `url` succeeded. Returns the file's download URL.
      operationId: completeFileUpload
      tags: [Files]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fileId]
              properties:
                fileId:
                  type: string
                  description: The `fileId` returned by `/api/files/upload-url`.
      responses:
        "200":
          description: Upload finalized.
          content:
            application/json:
              schema:
                type: object
                properties:
                  fileId:
                    type: string
                    description: ID of the stored file.
                  url:
                    type: string
                    description: Download URL for the stored file.
        "400":
          description: Missing fileId or the upload was already completed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Invalid or missing credential.
        "404":
          description: No pending upload found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/web-forms/{formId}:
    post:
      summary: Submit a web form
      description: |
        Submits data to an Aptly Web Form. The request must use the API key
        issued for this exact form and that key must have insert access to the
        form's board.

        Submitted board-field values are keyed by field UUID. Depending on the
        form's configuration, the submission creates a card or updates a card
        whose configured match fields have the same values. Unknown properties
        are ignored. Contact shortcut properties can link the card to an
        existing contact or create a new contact when the form and board allow it.
      operationId: submitWebForm
      tags: [WebForms]
      security:
        - ApiKeyHeader: []
      parameters:
        - name: formId
          in: path
          required: true
          schema:
            type: string
          description: Web Form ID.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: Card name. Takes precedence over `title`.
                title:
                  type: string
                  description: Card name when `name` is omitted.
                contactFirstName:
                  type: string
                  description: Contact first name used by automatic contact linking.
                contactLastName:
                  type: string
                  description: Contact last name used by automatic contact linking.
                contactFullName:
                  type: string
                  description: Contact full name used by automatic contact linking.
                contactEmail:
                  type: string
                  format: email
                  description: Contact email used for lookup or creation.
                contactPhone:
                  type: string
                  description: Contact phone number used for lookup or creation.
              additionalProperties:
                description: Board field value keyed by the field UUID configured on the form's board.
      responses:
        "200":
          description: Submission accepted and its card created or updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    required: [_id, action]
                    properties:
                      _id:
                        type: string
                        description: ID of the created or updated card.
                      action:
                        type: string
                        enum: [created, updated]
        "400":
          description: The board API is disabled or the submitted data is invalid.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Invalid or missing API key.
        "403":
          description: The API key lacks insert or board access, or was not issued for this form.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Form or board not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/tours/feedback:
    get:
      summary: List tour feedback
      description: |
        Returns post-tour feedback surveys for the company, newest first.

        A survey is created when a tour showing is approved on a board with tour
        feedback enabled, and the applicant answers it through an emailed link.

        Accepts an API key (`x-token`), delegate token (`Authorization: DelegateToken <token>`
        with the `tours` scope), or partner token (`Authorization: Bearer <token>` with
        `boards` permission). When using a partner token, pass `companyId` as a query parameter.
      operationId: listTourFeedback
      tags: [Tours]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: companyId
          in: query
          schema:
            type: string
          description: Company ID. Required when using a partner token; resolved automatically for API key and delegate token auth.
        - name: instanceId
          in: query
          schema:
            type: string
          description: Only surveys for this location/unit card.
        - name: status
          in: query
          schema:
            type: string
            enum: [completed, pending, all]
            default: completed
          description: Whether to return answered surveys, surveys still awaiting an answer, or both.
        - name: startDate
          in: query
          schema:
            type: string
            format: date-time
          description: Only surveys completed on or after this ISO date.
        - name: endDate
          in: query
          schema:
            type: string
            format: date-time
          description: Only surveys completed on or before this ISO date.
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
            maximum: 200
          description: Page size. Values above 200 are clamped.
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
      responses:
        "200":
          description: A page of tour feedback.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/TourFeedback"
                  total:
                    type: integer
                    description: Total matching surveys, ignoring limit/offset.
                  limit:
                    type: integer
                  offset:
                    type: integer
        "400":
          description: Missing companyId, or an invalid status, date, limit or offset.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Invalid or missing credential.

  /api/tours/{showingId}/feedback:
    get:
      summary: Get one showing's tour feedback
      description: |
        Returns the tour feedback survey for a single showing.

        A showing that belongs to another company reads as not found.
      operationId: getTourFeedback
      tags: [Tours]
      security:
        - ApiKeyHeader: []
        - DelegateToken: []
        - PartnerBearer: []
      parameters:
        - name: showingId
          in: path
          required: true
          schema:
            type: string
          description: Showing (portal application) ID.
        - name: companyId
          in: query
          schema:
            type: string
          description: Company ID. Required when using a partner token; resolved automatically for API key and delegate token auth.
      responses:
        "200":
          description: The tour feedback survey.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TourFeedback"
        "400":
          description: Missing companyId.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Invalid or missing credential.
        "404":
          description: No survey found for this showing in this company.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/docs/openapi:
    get:
      summary: Fetch this OpenAPI spec
      description: |
        Returns the raw contents of this OpenAPI spec (docs/openapi.yaml) as `text/yaml`.
        No authentication required.
      operationId: getPublicOpenApiSpec
      tags: [Docs]
      security: []
      responses:
        "200":
          description: The OpenAPI spec, as YAML.
          content:
            text/yaml:
              schema:
                type: string
