openapi: 3.1.0
info:
  title: NOCK Public API
  version: 1.0.0
  description: Server-to-server API for NOCK tickets, project metadata, webhooks, and delivery logs.
servers:
  - url: https://nocknock.cloud
security:
  - bearerAuth: []
  - apiKeyAuth: []
tags:
  - name: Projects
  - name: Tickets
  - name: Webhooks
paths:
  /api/v1/projects:
    get:
      tags: [Projects]
      summary: List project metadata available to the API key
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      responses:
        "200":
          description: Project metadata
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProjectListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
  /api/v1/tickets:
    get:
      tags: [Tickets]
      summary: List tickets in one project
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - name: page
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 20 }
        - name: status
          in: query
          schema: { type: string, default: open }
          description: Use open, all, or a concrete NOCK status.
        - name: category
          in: query
          schema: { type: string, maxLength: 50 }
        - name: sort
          in: query
          schema:
            type: string
            enum: [newest, oldest, status]
            default: newest
      responses:
        "200":
          description: Ticket page
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TicketListResponse"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
    post:
      tags: [Tickets]
      summary: Create a NOCK ticket
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TicketCreate"
      responses:
        "201":
          description: Ticket created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TicketCreateResponse"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
  /api/v1/tickets/{id}:
    get:
      tags: [Tickets]
      summary: Read one ticket with images, annotations, and customer-visible notes
      parameters:
        - $ref: "#/components/parameters/TicketId"
      responses:
        "200":
          description: Ticket detail
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TicketDetailResponse"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
    patch:
      tags: [Tickets]
      summary: Update ticket content or workflow fields
      parameters:
        - $ref: "#/components/parameters/TicketId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TicketUpdate"
      responses:
        "200":
          description: Ticket updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TicketDetailResponse"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
  /api/v1/tickets/{id}/notes:
    post:
      tags: [Tickets]
      summary: Add a customer-visible reply to a ticket
      parameters:
        - $ref: "#/components/parameters/TicketId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TicketReplyCreate"
      responses:
        "201":
          description: Reply created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TicketReplyResponse"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Reply could not be queued for required delivery side effects
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/webhooks:
    get:
      tags: [Webhooks]
      summary: List webhook endpoints
      parameters:
        - name: project_id
          in: query
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Webhooks
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
    post:
      tags: [Webhooks]
      summary: Create a webhook endpoint
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookCreate"
      responses:
        "201":
          description: Webhook created; the signing secret is returned only once.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookCreateResponse"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
  /api/v1/webhooks/{id}:
    delete:
      tags: [Webhooks]
      summary: Disable a webhook endpoint
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Webhook disabled
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DisableWebhookResponse"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
  /api/v1/webhook-deliveries:
    get:
      tags: [Webhooks]
      summary: List webhook delivery attempts for debugging
      parameters:
        - name: endpoint_id
          in: query
          schema: { type: string, format: uuid }
        - name: project_id
          in: query
          schema: { type: string, format: uuid }
        - name: ticket_id
          in: query
          schema: { type: string, format: uuid }
        - name: status
          in: query
          schema:
            type: string
            enum: [pending, processing, delivered, failed, exhausted, abandoned]
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
      responses:
        "200":
          description: Delivery attempts
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookDeliveryListResponse"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: NOCK API key
    apiKeyAuth:
      type: apiKey
      in: header
      name: x-nock-api-key
  parameters:
    ProjectId:
      name: project_id
      in: query
      required: true
      schema: { type: string, format: uuid }
    TicketId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
  responses:
    Unauthorized:
      description: Invalid, missing, or revoked API key
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    Forbidden:
      description: Missing scope, inactive billing, plan limit, or project access denied
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    NotFound:
      description: Resource not found or outside the API key workspace/project
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    RateLimited:
      description: Request exceeded a public API rate limit
      headers:
        Retry-After:
          schema: { type: string }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    ValidationError:
      description: Invalid input
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
  schemas:
    ErrorResponse:
      type: object
      required: [success, error]
      properties:
        success: { const: false }
        error: { type: string }
        code: { type: string }
    TicketStatus:
      type: string
      enum: [Backlog, Triage, Todo, In Progress, Done, Cancelled, Duplicate]
    TicketPriority:
      type: string
      enum: [low, normal, high, urgent]
    TicketConversationState:
      type: string
      enum: [none, waiting_for_customer, customer_replied]
    WebhookEvent:
      type: string
      enum: [ticket.created, ticket.updated, ticket.status_changed, ticket.note.created]
    WebhookDeliveryEvent:
      type: string
      enum: [ticket.created, ticket.updated, ticket.status_changed, ticket.note.created, webhook.test]
    Project:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        slug: { type: string }
        categories:
          type: array
          items:
            type: object
            properties:
              key: { type: string }
              label: { type: string }
              emoji: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    ProjectListResponse:
      type: object
      required: [success, data]
      properties:
        success: { const: true }
        data:
          type: object
          properties:
            projects:
              type: array
              items: { $ref: "#/components/schemas/Project" }
    TicketSummary:
      type: object
      description: Ticket fields returned by list and create responses. Images, annotations, and notes are returned by the ticket detail endpoint.
      properties:
        id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        title: { type: string }
        description: { type: [string, "null"] }
        category: { type: string }
        page_url: { type: string }
        status: { anyOf: [{ $ref: "#/components/schemas/TicketStatus" }, { type: "null" }] }
        priority: { $ref: "#/components/schemas/TicketPriority" }
        assignee_user_id: { type: [string, "null"], format: uuid }
        reporter_email: { type: [string, "null"], format: email }
        conversation_state: { $ref: "#/components/schemas/TicketConversationState" }
        unread_customer_reply_count: { type: integer, minimum: 0 }
        last_customer_reply_at: { type: [string, "null"], format: date-time }
        last_public_activity_at: { type: [string, "null"], format: date-time }
        last_public_activity_type: { type: [string, "null"] }
        last_public_activity_summary: { type: [string, "null"] }
        last_team_reply_at: { type: [string, "null"], format: date-time }
        source: { type: string }
        created_at: { type: string, format: date-time }
    Ticket:
      type: object
      description: Full ticket detail returned by the ticket detail endpoint.
      properties:
        id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        title: { type: string }
        description: { type: [string, "null"] }
        category: { type: string }
        page_url: { type: string }
        status: { anyOf: [{ $ref: "#/components/schemas/TicketStatus" }, { type: "null" }] }
        priority: { $ref: "#/components/schemas/TicketPriority" }
        assignee_user_id: { type: [string, "null"], format: uuid }
        reporter_email: { type: [string, "null"], format: email }
        conversation_state: { $ref: "#/components/schemas/TicketConversationState" }
        unread_customer_reply_count: { type: integer, minimum: 0 }
        last_customer_reply_at: { type: [string, "null"], format: date-time }
        last_public_activity_at: { type: [string, "null"], format: date-time }
        last_public_activity_type: { type: [string, "null"] }
        last_public_activity_summary: { type: [string, "null"] }
        last_team_reply_at: { type: [string, "null"], format: date-time }
        source: { type: string }
        created_at: { type: string, format: date-time }
        images:
          type: array
          items: { $ref: "#/components/schemas/TicketImage" }
        annotations:
          type: array
          description: Website annotations captured by the widget, including pins and text-edit suggestions.
          items: { $ref: "#/components/schemas/TicketAnnotation" }
        notes:
          type: array
          description: Customer-visible notes only. Internal notes are never returned by the public API.
          items: { $ref: "#/components/schemas/TicketNote" }
    TicketImage:
      type: object
      properties:
        id: { type: string, format: uuid }
        storage_url:
          type: [string, "null"]
          format: uri
          description: >-
            Short-lived signed URL for the attachment. Expires within five
            minutes; request the ticket detail again to refresh it.
        original_filename: { type: [string, "null"] }
        file_size: { type: [integer, "null"] }
        mime_type: { type: [string, "null"] }
        visibility:
          type: string
          enum: [internal, customer]
          description: >-
            Whether the asset is shown in the customer portal. Widget and portal
            uploads are `customer`; assets attached by an operator from the
            dashboard default to `internal`. Both are returned here — this API
            is authenticated with the workspace's own key.
        created_at: { type: string, format: date-time }
    TicketAnnotation:
      oneOf:
        - type: object
          required: [id, type, page_url, x, y, x_ratio, y_ratio]
          properties:
            id: { type: string, format: uuid }
            type: { type: string, const: pin }
            page_url: { type: string }
            viewport_width: { type: [integer, "null"] }
            viewport_height: { type: [integer, "null"] }
            selector: { type: [string, "null"] }
            element_text: { type: [string, "null"] }
            screenshot_image_id: { type: [string, "null"], format: uuid }
            x: { type: integer, minimum: 0, maximum: 10000 }
            y: { type: integer, minimum: 0, maximum: 10000 }
            x_ratio: { type: number, minimum: 0, maximum: 1 }
            y_ratio: { type: number, minimum: 0, maximum: 1 }
            comment: { type: [string, "null"] }
            created_at: { type: string, format: date-time }
        - type: object
          required: [id, type, page_url, original_text, suggested_text]
          properties:
            id: { type: string, format: uuid }
            type: { type: string, const: text_edit }
            page_url: { type: string }
            viewport_width: { type: [integer, "null"] }
            viewport_height: { type: [integer, "null"] }
            selector: { type: [string, "null"] }
            element_text: { type: [string, "null"] }
            original_text: { type: string }
            suggested_text: { type: string }
            comment: { type: [string, "null"] }
            created_at: { type: string, format: date-time }
    TicketNote:
      type: object
      properties:
        id: { type: string, format: uuid }
        body: { type: string }
        visibility: { type: string, const: customer }
        author_type: { type: string }
        author_name: { type: [string, "null"] }
        author_user_id: { type: [string, "null"], format: uuid }
        created_at: { type: string, format: date-time }
    TicketCreate:
      type: object
      required: [project_id, title, page_url]
      properties:
        project_id: { type: string, format: uuid }
        title: { type: string, minLength: 1, maxLength: 200 }
        description: { type: string, maxLength: 5000 }
        reporter_email: { type: string, format: email, maxLength: 254 }
        category: { type: string, minLength: 1, maxLength: 50, default: bug }
        page_url: { type: string, minLength: 1, maxLength: 2000 }
        browser_name: { type: string, maxLength: 100 }
        browser_version: { type: string, maxLength: 50 }
        os_name: { type: string, maxLength: 100 }
        os_version: { type: string, maxLength: 50 }
        viewport_width: { type: integer, minimum: 0, maximum: 10000 }
        viewport_height: { type: integer, minimum: 0, maximum: 10000 }
        screen_resolution: { type: string, maxLength: 20 }
        console_logs:
          type: array
          maxItems: 50
          items:
            type: object
            properties:
              level:
                type: string
                enum: [error, warn, info, log, debug]
              message: { type: string, maxLength: 2000 }
              timestamp: { type: string }
              stack: { type: string, maxLength: 5000 }
    TicketUpdate:
      type: object
      minProperties: 1
      properties:
        title: { type: string, minLength: 1, maxLength: 200 }
        description: { type: [string, "null"], maxLength: 5000 }
        reporter_email: { type: [string, "null"], format: email, maxLength: 254 }
        category: { type: string, minLength: 1, maxLength: 50 }
        status: { anyOf: [{ $ref: "#/components/schemas/TicketStatus" }, { type: "null" }] }
        priority: { $ref: "#/components/schemas/TicketPriority" }
        assignee_user_id: { type: [string, "null"], format: uuid }
    TicketCreateResponse:
      type: object
      properties:
        success: { const: true }
        data:
          type: object
          properties:
            ticket: { $ref: "#/components/schemas/TicketSummary" }
            over_quota: { type: boolean }
    TicketListResponse:
      type: object
      properties:
        success: { const: true }
        data:
          type: object
          properties:
            tickets:
              type: array
              items: { $ref: "#/components/schemas/TicketSummary" }
            pagination:
              type: object
              properties:
                page: { type: integer }
                total: { type: integer }
                total_pages: { type: integer }
    TicketDetailResponse:
      type: object
      properties:
        success: { const: true }
        data:
          type: object
          properties:
            ticket: { $ref: "#/components/schemas/Ticket" }
    TicketReplyCreate:
      type: object
      required: [body]
      properties:
        body: { type: string, minLength: 1, maxLength: 4000 }
        reporter_email: { type: string, format: email, maxLength: 254 }
    TicketReplyResponse:
      type: object
      properties:
        success: { const: true }
        data:
          type: object
          properties:
            reply:
              type: object
              properties:
                id: { type: string, format: uuid }
                body: { type: string }
                visibility: { type: string, const: customer }
                author_type: { type: string, const: customer }
                author_email: { type: [string, "null"], format: email }
                created_at: { type: string, format: date-time }
    Webhook:
      type: object
      properties:
        id: { type: string, format: uuid }
        project_id: { type: [string, "null"], format: uuid }
        url: { type: string, format: uri }
        events:
          type: array
          items: { $ref: "#/components/schemas/WebhookEvent" }
        is_active: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    WebhookCreate:
      type: object
      required: [url]
      properties:
        url:
          type: string
          format: uri
          maxLength: 2048
          pattern: "^https://"
        project_id: { type: [string, "null"], format: uuid }
        events:
          type: array
          minItems: 1
          maxItems: 4
          items: { $ref: "#/components/schemas/WebhookEvent" }
    WebhookCreateResponse:
      type: object
      properties:
        success: { const: true }
        data:
          type: object
          properties:
            webhook: { $ref: "#/components/schemas/Webhook" }
            secret: { type: string }
    WebhookListResponse:
      type: object
      properties:
        success: { const: true }
        data:
          type: object
          properties:
            webhooks:
              type: array
              items: { $ref: "#/components/schemas/Webhook" }
    DisableWebhookResponse:
      type: object
      properties:
        success: { const: true }
        data:
          type: object
          properties:
            id: { type: string, format: uuid }
            active: { type: boolean, const: false }
    WebhookDelivery:
      type: object
      properties:
        id: { type: string, format: uuid }
        endpoint_id: { type: [string, "null"], format: uuid }
        project_id: { type: [string, "null"], format: uuid }
        ticket_id: { type: [string, "null"], format: uuid }
        event_type: { $ref: "#/components/schemas/WebhookDeliveryEvent" }
        status:
          type: string
          enum: [pending, processing, delivered, failed, exhausted, abandoned]
        attempt_count: { type: integer }
        max_attempts: { type: integer }
        response_status: { type: [integer, "null"] }
        error: { type: [string, "null"] }
        delivered_at: { type: [string, "null"], format: date-time }
        last_attempt_at: { type: [string, "null"], format: date-time }
        next_attempt_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    WebhookDeliveryListResponse:
      type: object
      properties:
        success: { const: true }
        data:
          type: object
          properties:
            deliveries:
              type: array
              items: { $ref: "#/components/schemas/WebhookDelivery" }
