openapi: 3.1.0
info:
  title: Gatwyn public API
  version: '1.0.0'
  description: |
    Machine access to a Gatwyn workspace.

    **Authentication.** Send an API key as a bearer token:
    `Authorization: Bearer gwn_<workspace>_<secret>`. A key belongs to exactly one workspace, and
    the workspace is part of the token, so a request can never address a different one. Keys are
    created in the app under Settings → API keys and the secret is shown only at creation; only a
    SHA-256 hash is stored, so a lost key must be revoked and replaced.

    **Scopes.** A key carries an explicit set of scopes and can never hold more than the person
    who created it. Only scopes the API implements can be granted; see the `scopes` list below.

    **Rate limits.** Each key has its own allowance, 120 requests per minute by default, counted
    in fixed one-minute windows. Every response carries `X-RateLimit-Limit`,
    `X-RateLimit-Remaining` and `X-RateLimit-Reset` (Unix seconds); a refused request answers 429
    with `Retry-After` in seconds.

    **Pagination.** List endpoints return newest first and page with an opaque `cursor`. Read
    `nextCursor` from a response and pass it back as `?cursor=`; its absence means the last page.
    Do not parse a cursor: the format is not part of this contract. An unreadable cursor restarts
    from the newest record rather than failing.

    **Request ids.** Every response carries `X-Request-Id`, and error bodies repeat it as
    `error.requestId`. Quote it in a support request. Send your own `X-Request-Id` to have it
    reused for correlation.

    **Idempotency.** Writes that create something (`POST /tickets`,
    `POST /tickets/{id}/messages`) accept an `Idempotency-Key` header: any unique value of 1 to
    255 visible ASCII characters, such as a UUID. Sending the same request again with the same key
    returns the first response, marked `Idempotent-Replayed: true`, instead of acting twice. A
    different request with a used key answers 422 `IDEMPOTENCY_KEY_REUSED`; one still in progress
    answers 409. A request that fails keeps nothing, so it can be corrected and retried with the
    same key. Keys belong to the API key that sent them and are forgotten after 24 hours.

    **Acting as a member.** Conversation endpoints act for the member who created the key. They may
    do what both the key's scopes and that member's current role allow. If the member leaves the
    workspace, the key reaches no conversations until it is replaced.

    **Webhooks.** A workspace can register up to 10 https receivers under API keys → Webhooks and
    choose events. Gatwyn POSTs JSON to each: `{ id, event, createdAt, workspaceId, data }`,
    where `data.conversation` has the same shape as `Ticket` and `data.message` the same as
    `Message`, built when the delivery is sent. See the `webhooks` section for each event.

    Every delivery carries `Gatwyn-Event`, `Gatwyn-Delivery` (the `id`, the same on every
    retry: use it to ignore duplicates) and `Gatwyn-Signature: t=<unix seconds>,v1=<hex>`. To
    check it, compute HMAC-SHA256 of `"<t>.<raw body>"` with the signing secret shown when the
    webhook was created, compare it with `v1` in constant time, and refuse a `t` more than five
    minutes old. Answer any 2xx within 10 seconds; anything else is retried after 1 minute, 5
    minutes, 30 minutes, 2 hours and 8 hours. 410 stops retries for that delivery. After 15
    deliveries in a row fail, the webhook is paused until someone resumes it. Deliveries are at
    least once and may arrive out of order; `createdAt` is when the change happened.
servers:
  - url: https://{host}/api/v1
    description: Gatwyn
    variables:
      host:
        default: app.example.com
        description: The host Gatwyn is deployed on. No public instance exists yet.

security:
  - bearerAuth: []

tags:
  - name: Members
    description: The people who belong to a workspace.
  - name: Conversations
    description: Conversations with customers, their messages, replies and notes.
  - name: Customers
    description: The people a workspace talks to, on any channel.

paths:
  /members:
    get:
      tags: [Members]
      summary: List workspace members
      description: |
        Members of the workspace the key belongs to, newest first.
      operationId: listMembers
      security:
        - bearerAuth: []
      parameters:
        - name: limit
          in: query
          description: How many members to return.
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
        - name: status
          in: query
          description: Only members in this state. All states are returned when omitted.
          required: false
          schema:
            type: string
            enum: [invited, active, disabled]
        - name: cursor
          in: query
          description: The `nextCursor` of a previous response. Continues after that page.
          required: false
          schema:
            type: string
      responses:
        '200':
          description: A page of members.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MemberPage'
              examples:
                page:
                  value:
                    data:
                      - id: 01a0a5a6-42bc-7194-8864-e18672612b7c
                        userId: 01a0a511-33c5-7768-9e96-51229a8f817a
                        name: Ada Lovelace
                        email: ada@example.com
                        role:
                          id: 01a0a511-3440-717f-8a83-dbc36b09c115
                          key: owner
                          name: Owner
                        status: active
                        joinedAt: '2026-09-15T12:36:03.412Z'
                    nextCursor: eyJpZCI6IjAxYTBhNWE2LTQyYmMtNzE5NC04ODY0LWUxODY3MjYxMmI3YyJ9
        '400':
          $ref: '#/components/responses/ValidationFailed'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /tickets:
    get:
      tags: [Conversations]
      summary: List conversations
      description: |
        Conversations in the workspace, most recent activity first. Needs `ticket.read`.

        With `q`, the conversations matching a search instead: the conversation number, the
        subject, the customer's name or address, or words in a message (internal notes only for
        keys with `ticket.note`). A search returns the best matches at once, newest first, without
        pages; `limited` is true when more matched than the response holds. `status` and `tag`
        still filter the matches.
      operationId: listTickets
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
        - name: status
          in: query
          description: One status, or several separated by commas, e.g. `open,pending`.
          required: false
          schema: { type: string, examples: ['open,pending'] }
        - name: tag
          in: query
          description: Only conversations with this tag, matched without regard to case.
          required: false
          schema: { type: string, maxLength: 40 }
        - name: cursor
          in: query
          description: Not allowed together with `q`.
          required: false
          schema: { type: string }
        - name: q
          in: query
          description: Words to search for.
          required: false
          schema: { type: string, minLength: 2, maxLength: 200 }
      responses:
        '200':
          description: A page of conversations.
          headers:
            X-Request-Id: { $ref: '#/components/headers/XRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TicketPage' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Conversations]
      summary: Open a conversation
      description: |
        Opens a conversation for a customer, with their first message, as the team's "New ticket"
        does. The customer is found by email or created. Needs `ticket.write`. Send an
        `Idempotency-Key` so a retried request opens it once.
      operationId: createTicket
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateTicket' }
            example:
              subject: Order 4821 has not arrived
              requester: { name: Giulia Romano, email: giulia@example.it }
              message: The tracking has not moved since Monday.
              priority: high
              tags: [Shipping]
      responses:
        '201':
          description: The conversation, with its messages.
          headers:
            Idempotent-Replayed:
              description: '`true` when this is the stored response to an earlier identical request.'
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TicketDetail' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/IdempotencyKeyReused' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /tickets/{id}:
    parameters:
      - $ref: '#/components/parameters/TicketId'
    get:
      tags: [Conversations]
      summary: Read a conversation
      description: |
        A conversation and all its messages, oldest first. Internal notes are included only for keys
        with `ticket.note`; `includesInternalNotes` says which. Needs `ticket.read`.
      operationId: getTicket
      responses:
        '200':
          description: The conversation.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TicketDetail' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      tags: [Conversations]
      summary: Update a conversation
      description: |
        Changes the status, priority, assignee or tags. Only the fields sent change; `tags`
        replaces the conversation's tags. Needs `ticket.manage`.
      operationId: updateTicket
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateTicket' }
            example: { status: solved, tags: [Shipping, Refund] }
      responses:
        '200':
          description: The conversation after the change.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TicketDetail' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /tickets/{id}/messages:
    parameters:
      - $ref: '#/components/parameters/TicketId'
    post:
      tags: [Conversations]
      summary: Reply or add a note
      description: |
        A `public_reply` goes to the customer on the conversation's own channel (email, WhatsApp,
        Messenger, Instagram or the website chat), exactly like a reply from the app, and needs
        `ticket.write`. An `internal_note` is never sent and needs `ticket.note`. Messages appear
        as written by the member who created the key; the audit log names the key. Send an
        `Idempotency-Key` so a retried request sends once.
      operationId: createMessage
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateMessage' }
            example: { kind: public_reply, body: 'Hi Giulia, it arrives tomorrow.' }
      responses:
        '201':
          description: The message, and what happened to it.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MessageResult' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/IdempotencyKeyReused' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /tags:
    get:
      tags: [Conversations]
      summary: List tags
      description: |
        The workspace's conversation tags, alphabetical, with how many conversations carry each.
        A workspace has at most 200 tags, so there are no pages. Needs `ticket.read`.
      operationId: listTags
      responses:
        '200':
          description: Every tag.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Tag' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /attachments/{id}:
    get:
      tags: [Conversations]
      summary: Download a file
      description: |
        A file sent with a message, as itself (not JSON), always as a download. The type served
        is decided from the file's contents, so a file never runs as something else. Files on
        internal notes need `ticket.note`; a file the key may not see is a 404. Needs
        `ticket.read`.
      operationId: getAttachment
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: The file.
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /customers:
    get:
      tags: [Customers]
      summary: List customers
      description: |
        The workspace's customers, newest first. `q` matches part of a name or email address, or
        the digits of a WhatsApp number. Pages hold a fixed number of customers. Needs
        `ticket.read`.
      operationId: listCustomers
      parameters:
        - name: q
          in: query
          required: false
          schema: { type: string, maxLength: 200 }
        - name: cursor
          in: query
          required: false
          schema: { type: string }
      responses:
        '200':
          description: A page of customers.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CustomerPage' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /customers/{id}:
    get:
      tags: [Customers]
      summary: Get a customer
      description: |
        One customer, the channels they write on and their latest conversations. The team's notes
        about the customer are included only for keys with `ticket.note`. Needs `ticket.read`.
      operationId: getCustomer
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: The customer.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CustomerDetail' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

webhooks:
  conversation.created:
    post:
      summary: A conversation is opened
      description: Any channel, including one opened by the API. `data.conversation` only.
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookDelivery' }
      responses:
        '200': { description: Any 2xx acknowledges the delivery. }
  conversation.updated:
    post:
      summary: A conversation's status, priority or assignee changes
      description: |
        `data.changes` lists what changed as `{ field: [from, to] }`, for `status`,
        `priority` and `assigneeMembershipId`. `data.conversation` is the current state.
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookDelivery' }
      responses:
        '200': { description: Any 2xx acknowledges the delivery. }
  message.created:
    post:
      summary: A customer writes or the team replies
      description: |
        Every public message, including a conversation's first one and replies written by Pilot.
        `data.message` is the message, `data.conversation` its conversation.
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookDelivery' }
      responses:
        '200': { description: Any 2xx acknowledges the delivery. }
  note.created:
    post:
      summary: An internal note is added
      description: Notes are never sent unless this event is chosen. `data.message` is the note.
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookDelivery' }
      responses:
        '200': { description: Any 2xx acknowledges the delivery. }
  ping:
    post:
      summary: A test sent from the Webhooks panel
      description: '`data` is empty.'
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookDelivery' }
      responses:
        '200': { description: Any 2xx acknowledges the delivery. }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        An API key created in the workspace. Scopes available today:
        `member.read` — list the people in the workspace;
        `ticket.read` — list and read conversations, without internal notes;
        `ticket.write` — open conversations and reply to customers;
        `ticket.note` — read and add internal notes;
        `ticket.manage` — change status, priority, assignee and tags.

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: A unique value per intended action; a repeat returns the first response.
      schema: { type: string, pattern: '^[\x21-\x7e]{1,255}$' }
    TicketId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }

  headers:
    XRequestId:
      description: Identifier for this request, safe to quote in a support conversation.
      schema:
        type: string
    XRateLimitLimit:
      description: Requests this key may make per minute.
      schema:
        type: integer
    XRateLimitRemaining:
      description: Requests left in the current window.
      schema:
        type: integer
    XRateLimitReset:
      description: Unix time, in seconds, when the current window ends.
      schema:
        type: integer
    RetryAfter:
      description: Seconds to wait before retrying.
      schema:
        type: integer

  schemas:
    Member:
      type: object
      required: [id, userId, name, email, role, status, joinedAt]
      properties:
        id:
          type: string
          format: uuid
          description: Identifies the membership, not the person.
        userId:
          type: string
          format: uuid
          description: The person, stable across workspaces.
        name:
          type: [string, 'null']
        email:
          type: [string, 'null']
          format: email
        role:
          type: object
          required: [id, key, name]
          properties:
            id:
              type: string
              format: uuid
            key:
              type: string
              description: Stable identifier, e.g. `owner`. Match on this, not on `name`.
              examples: [owner, admin, agent, light_agent]
            name:
              type: string
              description: Display name, which a workspace may change.
        status:
          type: string
          enum: [invited, active, disabled]
        joinedAt:
          type: string
          format: date-time

    MemberPage:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Member'
        nextCursor:
          type: string
          description: Pass back as `?cursor=`. Absent on the last page.

    Ticket:
      type: object
      required:
        [
          id,
          number,
          subject,
          status,
          priority,
          channel,
          requester,
          assignee,
          tags,
          sla,
          createdAt,
          lastActivityAt,
        ]
      properties:
        id: { type: string, format: uuid }
        number: { type: integer, description: 'The number people see, e.g. #1042.' }
        subject: { type: string }
        status: { type: string, enum: [new, open, pending, solved, closed] }
        priority: { type: string, enum: [low, normal, high, urgent] }
        channel:
          type: string
          description: Where the conversation began.
          examples: [email, whatsapp, messenger, instagram, chat, form, web]
        requester:
          type: object
          required: [id, name, email, whatsapp]
          properties:
            id: { type: string, format: uuid }
            name: { type: string }
            email: { type: [string, 'null'] }
            whatsapp: { type: [string, 'null'], examples: ['+393481234567'] }
        assignee:
          type: [object, 'null']
          required: [membershipId, name]
          properties:
            membershipId: { type: string, format: uuid }
            name: { type: string }
        tags: { type: array, items: { type: string } }
        sla:
          type: object
          properties:
            firstResponseDueAt: { type: [string, 'null'], format: date-time }
            firstRespondedAt: { type: [string, 'null'], format: date-time }
            resolutionDueAt: { type: [string, 'null'], format: date-time }
        createdAt: { type: string, format: date-time }
        lastActivityAt: { type: string, format: date-time }

    Message:
      type: object
      required: [id, kind, author, body, createdAt, attachments]
      properties:
        id: { type: string, format: uuid }
        kind: { type: string, enum: [public_reply, internal_note] }
        author:
          type: object
          required: [type, name]
          properties:
            type: { type: string, enum: [customer, agent, system, ai_agent] }
            name: { type: string }
        body: { type: string }
        createdAt: { type: string, format: date-time }
        attachments:
          type: array
          description: Files on the message. Download one from `url`.
          items:
            type: object
            required: [id, filename, contentType, sizeBytes, url]
            properties:
              id: { type: string, format: uuid }
              filename: { type: string }
              contentType: { type: string }
              sizeBytes: { type: [integer, 'null'] }
              url:
                type: string
                description: Path of GET /api/v1/attachments/{id}, relative to the API's host.
                examples: [/api/v1/attachments/0190c3a1-8b7e-7a51-9c1d-2f4e5a6b7c8d]

    TicketDetail:
      allOf:
        - $ref: '#/components/schemas/Ticket'
        - type: object
          required: [messages, includesInternalNotes]
          properties:
            messages:
              type: array
              items: { $ref: '#/components/schemas/Message' }
            includesInternalNotes:
              type: boolean
              description: False when the key may not read internal notes, which are then left out.

    TicketPage:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/Ticket' }
        nextCursor:
          type: string
          description: Pass back as `?cursor=`. Absent on the last page.
        limited:
          type: boolean
          description: Only with `q`. True when more conversations matched than were returned.

    Tag:
      type: object
      required: [id, name, conversations]
      properties:
        id: { type: string, format: uuid }
        name: { type: string, maxLength: 40 }
        conversations: { type: integer, minimum: 0 }

    Customer:
      type: object
      required: [id, name, email, whatsapp, conversations, openConversations, lastContactAt]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        email: { type: [string, 'null'], format: email }
        whatsapp: { type: [string, 'null'], description: 'E.164, e.g. +393331234567.' }
        conversations: { type: integer, minimum: 0 }
        openConversations: { type: integer, minimum: 0, description: Not solved or closed. }
        lastContactAt: { type: [string, 'null'], format: date-time }

    CustomerPage:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/Customer' }
        nextCursor:
          type: string
          description: Pass back as `?cursor=`. Absent on the last page.

    CustomerDetail:
      type: object
      required:
        [
          id,
          name,
          email,
          whatsapp,
          channels,
          includesNotes,
          createdAt,
          conversations,
          moreConversations,
        ]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        email: { type: [string, 'null'], format: email }
        whatsapp: { type: [string, 'null'] }
        channels:
          type: object
          required: [email, whatsapp, messenger, instagram]
          properties:
            email: { type: boolean }
            whatsapp: { type: boolean }
            messenger: { type: boolean }
            instagram: { type: boolean }
        notes:
          type: [string, 'null']
          description: Only for keys with `ticket.note`.
        includesNotes: { type: boolean }
        createdAt: { type: string, format: date-time }
        conversations:
          type: array
          description: The latest conversations, newest first.
          items:
            type: object
            required: [id, number, subject, status, channel, createdAt, lastActivityAt]
            properties:
              id: { type: string, format: uuid }
              number: { type: integer }
              subject: { type: string }
              status: { type: string, enum: [new, open, pending, solved, closed] }
              channel: { type: string }
              createdAt: { type: string, format: date-time }
              lastActivityAt: { type: string, format: date-time }
        moreConversations: { type: boolean, description: True when older conversations exist. }

    WebhookDelivery:
      type: object
      required: [id, event, createdAt, workspaceId, data]
      properties:
        id: { type: string, format: uuid, description: The same on every retry. }
        event:
          type: string
          enum: [conversation.created, conversation.updated, message.created, note.created, ping]
        createdAt: { type: string, format: date-time }
        workspaceId: { type: string, format: uuid }
        data:
          type: object
          properties:
            conversation: { $ref: '#/components/schemas/Ticket' }
            message: { $ref: '#/components/schemas/Message' }
            changes:
              type: object
              additionalProperties:
                type: array
                minItems: 2
                maxItems: 2

    CreateTicket:
      type: object
      required: [subject, requester, message]
      properties:
        subject: { type: string, minLength: 1, maxLength: 300 }
        requester:
          type: object
          required: [name]
          properties:
            name: { type: string, minLength: 1, maxLength: 200 }
            email: { type: string, format: email, maxLength: 320 }
        message:
          {
            type: string,
            minLength: 1,
            maxLength: 50000,
            description: "The customer's first message.",
          }
        priority: { type: string, enum: [low, normal, high, urgent], default: normal }
        tags: { type: array, maxItems: 10, items: { type: string, maxLength: 40 } }

    UpdateTicket:
      type: object
      minProperties: 1
      properties:
        status: { type: string, enum: [new, open, pending, solved, closed] }
        priority: { type: string, enum: [low, normal, high, urgent] }
        assigneeMembershipId:
          type: [string, 'null']
          format: uuid
          description: A member's `id` from `GET /members`, or null to unassign.
        tags: { type: array, maxItems: 10, items: { type: string, maxLength: 40 } }

    CreateMessage:
      type: object
      required: [kind, body]
      properties:
        kind: { type: string, enum: [public_reply, internal_note] }
        body: { type: string, minLength: 1, maxLength: 50000 }

    MessageResult:
      type: object
      required: [message, delivery]
      properties:
        message: { $ref: '#/components/schemas/Message' }
        delivery:
          type: object
          required: [status]
          description: |
            `queued`: on its way on `channel`. `posted`: shown in the website chat. `saved_only`:
            kept on the conversation but not sent, with the `reason` (for example `suppressed` when
            the customer's address bounced). `not_sent`: an internal note.
          properties:
            status: { type: string, enum: [queued, posted, saved_only, not_sent] }
            channel: { type: string, enum: [email, whatsapp, messenger, instagram, chat] }
            reason: { type: string }

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, requestId]
          properties:
            code:
              type: string
              description: Stable machine-readable code. Branch on this, not on `message`.
              enum:
                - VALIDATION_FAILED
                - UNAUTHENTICATED
                - FORBIDDEN
                - NOT_FOUND
                - CONFLICT
                - IDEMPOTENCY_KEY_REUSED
                - RATE_LIMITED
                - INTERNAL
                - SERVICE_UNAVAILABLE
            message:
              type: string
              description: Human-readable explanation. Wording may change; do not parse it.
            requestId:
              type: string
            details:
              description: Present when the code implies more, e.g. which fields failed.

  responses:
    ValidationFailed:
      description: A query parameter was missing or could not be honoured.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: VALIDATION_FAILED
              message: Some fields are missing or invalid.
              requestId: 01a0a5a6-42bc-7194-8864-e18672612b7c
              details:
                - path: limit
                  message: limit must be at most 100.
    Unauthenticated:
      description: |
        No key, or a key that is unknown, revoked or expired. The response carries
        `WWW-Authenticate: Bearer`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: UNAUTHENTICATED
              message: That API key is not valid.
              requestId: 01a0a5a6-42bc-7194-8864-e18672612b7c
    Forbidden:
      description: The key is valid but does not carry the scope this endpoint needs.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: FORBIDDEN
              message: You do not have permission to do that.
              requestId: 01a0a5a6-42bc-7194-8864-e18672612b7c
              details:
                required: member.read
    RateLimited:
      description: The key used up its allowance for the current window.
      headers:
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/XRateLimitRemaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/XRateLimitReset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: RATE_LIMITED
              message: Too many requests for this API key. Retry shortly.
              requestId: 01a0a5a6-42bc-7194-8864-e18672612b7c
    NotFound:
      description: No such conversation in this workspace.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Conflict:
      description: A request with this Idempotency-Key is still being processed. Retry shortly.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    IdempotencyKeyReused:
      description: The Idempotency-Key was already used for a different request.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: IDEMPOTENCY_KEY_REUSED
              message: This Idempotency-Key was already used for a different request. Use a new key.
              requestId: 01a0a5a6-42bc-7194-8864-e18672612b7c
    InternalError:
      description: Something failed on our side. The request id identifies it in our logs.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
