openapi: 3.1.0
info:
  title: q-api.de Klingel Platform API
  version: 1.0.0
  description: |
    Mandantenfähige Server-zu-Server-API für Türen, QR-Codes, Klingeln,
    Chat, Audio/Video-WebRTC, Geräte, Mitglieder und Einstellungen.
    Service-Tokens dürfen niemals an Browser oder mobile Clients ausgeliefert werden.
servers:
  - url: https://q-api.de/platform/v1
    description: Produktion
security:
  - serviceToken: []
tags:
  - name: System
  - name: Doors
  - name: QR
  - name: Rings
  - name: Communications
  - name: Messages
  - name: Photos
  - name: Devices
  - name: Members
  - name: Settings
paths:
  /health:
    get:
      tags: [System]
      operationId: health
      security: []
      responses:
        "200":
          description: Plattform ist bereit
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Health" }
  /config:
    get:
      tags: [System]
      operationId: config
      security: []
      responses:
        "200":
          description: Öffentliche API- und WebRTC-Konfiguration
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PlatformConfig" }
  /dashboard:
    get:
      tags: [System]
      operationId: getDashboard
      x-required-scope: dashboard:read
      responses: { "200": { $ref: "#/components/responses/Resource" }, "401": { $ref: "#/components/responses/Unauthorized" }, "403": { $ref: "#/components/responses/Forbidden" } }
  /doors:
    get:
      tags: [Doors]
      operationId: listDoors
      x-required-scope: doors:read
      parameters:
        - { name: status, in: query, schema: { type: string, enum: [all, active, paused] } }
        - { name: search, in: query, schema: { type: string, maxLength: 100 } }
      responses: { "200": { $ref: "#/components/responses/ResourceList" }, "401": { $ref: "#/components/responses/Unauthorized" }, "403": { $ref: "#/components/responses/Forbidden" } }
    post:
      tags: [Doors]
      operationId: createDoor
      x-required-scope: doors:write
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody: { $ref: "#/components/requestBodies/JsonObject" }
      responses: { "201": { $ref: "#/components/responses/Resource" }, "409": { $ref: "#/components/responses/Conflict" } }
  /doors/{doorId}:
    parameters: [{ $ref: "#/components/parameters/DoorId" }]
    get:
      tags: [Doors]
      operationId: getDoor
      x-required-scope: doors:read
      responses: { "200": { $ref: "#/components/responses/Resource" }, "404": { $ref: "#/components/responses/NotFound" } }
    patch:
      tags: [Doors]
      operationId: updateDoor
      x-required-scope: doors:write
      requestBody: { $ref: "#/components/requestBodies/JsonObject" }
      responses: { "200": { $ref: "#/components/responses/Resource" }, "404": { $ref: "#/components/responses/NotFound" } }
    delete:
      tags: [Doors]
      operationId: deleteDoor
      x-required-scope: doors:write
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      responses: { "200": { $ref: "#/components/responses/Ok" }, "404": { $ref: "#/components/responses/NotFound" }, "409": { $ref: "#/components/responses/Conflict" } }
  /doors/{doorId}/qr:
    parameters: [{ $ref: "#/components/parameters/DoorId" }]
    get:
      tags: [QR]
      operationId: getQrStudio
      x-required-scope: qr:read
      responses:
        "200":
          description: QR-Studio mit vollständiger normalisierter Schildgestaltung
          content:
            application/json:
              schema: { $ref: "#/components/schemas/QrStudio" }
        "404": { $ref: "#/components/responses/NotFound" }
  /doors/{doorId}/qr/artifact:
    parameters:
      - { $ref: "#/components/parameters/DoorId" }
      - { name: format, in: query, description: "svg/png liefern den reinen QR-Code; sign_svg/pdf das vollständige Türschild.", schema: { type: string, enum: [svg, png, sign_svg, pdf], default: svg } }
    get:
      tags: [QR]
      operationId: downloadQrArtifact
      x-required-scope: qr:read
      responses:
        "200":
          description: QR-Artefakt
          content:
            image/svg+xml: { schema: { type: string, format: binary } }
            image/png: { schema: { type: string, format: binary } }
            application/pdf: { schema: { type: string, format: binary } }
        "404": { $ref: "#/components/responses/NotFound" }
  /doors/{doorId}/qr/health:
    parameters: [{ $ref: "#/components/parameters/DoorId" }]
    get:
      tags: [QR]
      operationId: getQrHealth
      x-required-scope: qr:read
      responses: { "200": { $ref: "#/components/responses/Resource" } }
  /doors/{doorId}/qr/design:
    parameters: [{ $ref: "#/components/parameters/DoorId" }]
    put:
      tags: [QR]
      operationId: updateQrDesign
      x-required-scope: qr:write
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/QrDesignInput" }
      responses:
        "200":
          description: Schildgestaltung gespeichert; QR-Capability unverändert
          content:
            application/json:
              schema: { $ref: "#/components/schemas/QrDesignResult" }
        "400": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Conflict" }
  /doors/{doorId}/qr/rotate:
    parameters: [{ $ref: "#/components/parameters/DoorId" }]
    post:
      tags: [QR]
      operationId: rotateQr
      x-required-scope: qr:write
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody: { $ref: "#/components/requestBodies/OptionalJsonObject" }
      responses: { "200": { $ref: "#/components/responses/Resource" }, "409": { $ref: "#/components/responses/Conflict" } }
  /rings:
    get:
      tags: [Rings]
      operationId: listRings
      x-required-scope: rings:read
      parameters:
        - { name: status, in: query, schema: { type: string, default: all } }
        - { name: doorId, in: query, schema: { type: string, maxLength: 180 } }
      responses: { "200": { $ref: "#/components/responses/ResourceList" } }
  /rings/{ringId}:
    parameters: [{ $ref: "#/components/parameters/RingId" }]
    get:
      tags: [Rings]
      operationId: getRing
      x-required-scope: rings:read
      responses: { "200": { $ref: "#/components/responses/Resource" }, "404": { $ref: "#/components/responses/NotFound" } }
  /rings/{ringId}/response:
    parameters: [{ $ref: "#/components/parameters/RingId" }, { $ref: "#/components/parameters/IdempotencyKey" }]
    post:
      tags: [Rings]
      operationId: respondToRing
      x-required-scope: rings:respond
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [action]
              properties: { action: { type: string, enum: [accepted, declined] } }
      responses: { "200": { $ref: "#/components/responses/Resource" }, "409": { $ref: "#/components/responses/Conflict" } }
  /rings/{ringId}/block:
    parameters: [{ $ref: "#/components/parameters/RingId" }, { $ref: "#/components/parameters/IdempotencyKey" }]
    post:
      tags: [Rings]
      operationId: blockRingVisitor
      x-required-scope: rings:respond
      requestBody: { $ref: "#/components/requestBodies/JsonObject" }
      responses: { "200": { $ref: "#/components/responses/Ok" } }
  /test/rings:
    post:
      tags: [Rings]
      operationId: createTestRing
      x-required-scope: rings:write
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [doorId, confirmed]
              properties: { doorId: { type: string }, confirmed: { const: true } }
      responses: { "201": { $ref: "#/components/responses/Resource" }, "429": { $ref: "#/components/responses/RateLimited" } }
  /rings/{ringId}/communications:
    parameters: [{ $ref: "#/components/parameters/RingId" }, { $ref: "#/components/parameters/IdempotencyKey" }]
    post:
      tags: [Communications]
      operationId: startCommunication
      x-required-scope: communications:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [mode]
              properties:
                mode: { type: string, enum: [chat, audio, video] }
                intent: { type: string, enum: [conversation, photo], description: photo ist nur zusammen mit mode=chat zulässig. }
      responses: { "200": { $ref: "#/components/responses/Resource" }, "201": { $ref: "#/components/responses/Resource" }, "409": { $ref: "#/components/responses/Conflict" } }
  /rings/{ringId}/communications/end:
    parameters: [{ $ref: "#/components/parameters/RingId" }, { $ref: "#/components/parameters/IdempotencyKey" }]
    post:
      tags: [Communications]
      operationId: endCommunication
      x-required-scope: communications:write
      requestBody: { $ref: "#/components/requestBodies/JsonObject" }
      responses: { "200": { $ref: "#/components/responses/Resource" } }
  /rings/{ringId}/communications/{communicationId}/messages:
    parameters: [{ $ref: "#/components/parameters/RingId" }, { $ref: "#/components/parameters/CommunicationId" }]
    post:
      tags: [Communications]
      operationId: sendRingMessage
      x-required-scope: chat:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [body]
              properties: { body: { type: string, minLength: 1, maxLength: 2000 }, clientMessageId: { type: string, maxLength: 120 } }
      responses: { "201": { $ref: "#/components/responses/Resource" } }
  /rings/{ringId}/communications/{communicationId}/photos:
    parameters: [{ $ref: "#/components/parameters/RingId" }, { $ref: "#/components/parameters/CommunicationId" }]
    get:
      tags: [Photos]
      operationId: listCommunicationPhotos
      summary: Autorisierte Paketfotos einer Kommunikation auflisten
      x-required-scope: chat:read
      responses:
        "200":
          description: Fertig normalisierte Fotos, chronologisch aufsteigend
          content:
            application/json:
              schema:
                type: array
                maxItems: 5
                items: { $ref: "#/components/schemas/PhotoMessage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Photos]
      operationId: uploadCommunicationPhoto
      summary: Ein Paketfoto ohne Audio- oder Videoverbindung senden
      description: |
        Akzeptiert genau eine Datei. Der Server prüft Magic Bytes und
        Decodierbarkeit, richtet das Bild aus und kodiert Display- und
        Thumbnail-Variante als WebP neu. Metadaten wie EXIF und GPS werden
        nicht übernommen. `clientMessageId` dedupliziert sichere Wiederholungen.
      x-required-scope: chat:write
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema: { $ref: "#/components/schemas/PhotoUploadInput" }
            encoding:
              file:
                contentType: image/jpeg, image/png, image/webp
      responses:
        "200":
          description: Identische Wiederholung des bereits gespeicherten Uploads
          content: { application/json: { schema: { $ref: "#/components/schemas/PhotoMessage" } } }
        "201":
          description: Foto normalisiert und als Nachricht gespeichert
          content: { application/json: { schema: { $ref: "#/components/schemas/PhotoMessage" } } }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /media/{attachmentId}:
    parameters:
      - { $ref: "#/components/parameters/AttachmentId" }
      - name: variant
        in: query
        required: false
        schema: { type: string, enum: [thumbnail, display], default: display }
    get:
      tags: [Photos]
      operationId: getPhotoMedia
      summary: Private normalisierte Bildvariante abrufen
      description: Prüft bei jedem Abruf erneut Mandant, Tür und Ring. Die URL ist weder öffentlich noch dauerhaft signiert.
      x-required-scope: chat:read
      responses:
        "200":
          description: Normalisierte WebP-Bilddaten
          headers:
            Cache-Control: { schema: { type: string, const: "private, no-store" } }
            Content-Disposition: { schema: { type: string, const: "inline" } }
          content:
            image/webp:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /rings/{ringId}/communications/{communicationId}/signals:
    parameters: [{ $ref: "#/components/parameters/RingId" }, { $ref: "#/components/parameters/CommunicationId" }]
    post:
      tags: [Communications]
      operationId: sendWebRtcSignal
      x-required-scope: calls:write
      requestBody: { $ref: "#/components/requestBodies/JsonObject" }
      responses: { "202": { $ref: "#/components/responses/Ok" }, "409": { $ref: "#/components/responses/Conflict" } }
  /rings/{ringId}/communications/{communicationId}/heartbeat:
    parameters: [{ $ref: "#/components/parameters/RingId" }, { $ref: "#/components/parameters/CommunicationId" }]
    post:
      tags: [Communications]
      operationId: heartbeatCommunication
      x-required-scope: calls:write
      requestBody: { $ref: "#/components/requestBodies/OptionalJsonObject" }
      responses: { "200": { $ref: "#/components/responses/Resource" } }
  /messages:
    get:
      tags: [Messages]
      operationId: listThreads
      x-required-scope: chat:read
      responses: { "200": { $ref: "#/components/responses/ResourceList" } }
  /messages/{threadId}:
    parameters: [{ name: threadId, in: path, required: true, schema: { type: string } }]
    get:
      tags: [Messages]
      operationId: getThread
      x-required-scope: chat:read
      responses: { "200": { $ref: "#/components/responses/Resource" }, "404": { $ref: "#/components/responses/NotFound" } }
    post:
      tags: [Messages]
      operationId: sendThreadMessage
      x-required-scope: chat:write
      requestBody: { $ref: "#/components/requestBodies/JsonObject" }
      responses: { "201": { $ref: "#/components/responses/Resource" } }
  /devices:
    get:
      tags: [Devices]
      operationId: listDevices
      x-required-scope: devices:read
      responses: { "200": { $ref: "#/components/responses/ResourceList" } }
    post:
      tags: [Devices]
      operationId: createDevice
      x-required-scope: devices:write
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody: { $ref: "#/components/requestBodies/JsonObject" }
      responses: { "201": { $ref: "#/components/responses/Resource" } }
  /devices/{deviceId}:
    parameters: [{ name: deviceId, in: path, required: true, schema: { type: string } }]
    patch:
      tags: [Devices]
      operationId: updateDevice
      x-required-scope: devices:write
      requestBody: { $ref: "#/components/requestBodies/JsonObject" }
      responses: { "200": { $ref: "#/components/responses/Resource" } }
    delete:
      tags: [Devices]
      operationId: deleteDevice
      x-required-scope: devices:write
      responses: { "200": { $ref: "#/components/responses/Ok" } }
  /devices/{deviceId}/test:
    parameters: [{ name: deviceId, in: path, required: true, schema: { type: string } }]
    post:
      tags: [Devices]
      operationId: testDevicePush
      x-required-scope: devices:write
      requestBody: { $ref: "#/components/requestBodies/OptionalJsonObject" }
      responses: { "200": { $ref: "#/components/responses/Resource" }, "422": { $ref: "#/components/responses/Error" } }
  /members:
    get:
      tags: [Members]
      operationId: listMembers
      x-required-scope: members:read
      responses: { "200": { $ref: "#/components/responses/ResourceList" } }
  /members/invitations:
    post:
      tags: [Members]
      operationId: inviteMember
      x-required-scope: members:write
      requestBody: { $ref: "#/components/requestBodies/JsonObject" }
      responses: { "201": { $ref: "#/components/responses/Resource" }, "202": { $ref: "#/components/responses/Resource" } }
  /members/invitations/accept:
    post:
      tags: [Members]
      operationId: acceptMemberInvitation
      x-required-scope: members:write
      requestBody: { $ref: "#/components/requestBodies/JsonObject" }
      responses: { "200": { $ref: "#/components/responses/Resource" }, "409": { $ref: "#/components/responses/Conflict" } }
  /members/{memberId}:
    parameters: [{ name: memberId, in: path, required: true, schema: { type: string } }]
    patch:
      tags: [Members]
      operationId: updateMember
      x-required-scope: members:write
      requestBody: { $ref: "#/components/requestBodies/JsonObject" }
      responses: { "200": { $ref: "#/components/responses/Resource" } }
    delete:
      tags: [Members]
      operationId: deleteMember
      x-required-scope: members:write
      responses: { "200": { $ref: "#/components/responses/Ok" } }
  /settings:
    get:
      tags: [Settings]
      operationId: getSettings
      x-required-scope: settings:read
      responses: { "200": { $ref: "#/components/responses/Resource" } }
    patch:
      tags: [Settings]
      operationId: updateSettings
      x-required-scope: settings:write
      requestBody: { $ref: "#/components/requestBodies/JsonObject" }
      responses: { "200": { $ref: "#/components/responses/Resource" } }
  /live:
    get:
      tags: [System]
      operationId: openLiveWebSocket
      x-required-scope: events:read
      x-websocket: true
      parameters:
        - { name: since, in: query, description: Letzte bestätigte Sequenz, schema: { type: integer, minimum: 0 } }
      responses:
        "101": { description: WebSocket-Protokollwechsel; Events siehe Integrationshandbuch }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
components:
  securitySchemes:
    serviceToken:
      type: http
      scheme: bearer
      bearerFormat: qapi_v1_<token-id>_<secret>
      description: Nur serverseitig gespeichertes, mandantengebundenes Service-Token.
  parameters:
    DoorId: { name: doorId, in: path, required: true, schema: { type: string, minLength: 1, maxLength: 180 } }
    RingId: { name: ringId, in: path, required: true, schema: { type: string, minLength: 1, maxLength: 180 } }
    CommunicationId: { name: communicationId, in: path, required: true, schema: { type: string, minLength: 1, maxLength: 180 } }
    AttachmentId: { name: attachmentId, in: path, required: true, schema: { type: string, minLength: 1, maxLength: 180 } }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema: { type: string, pattern: "^[A-Za-z0-9_.:-]{16,128}$" }
  requestBodies:
    JsonObject:
      required: true
      content:
        application/json:
          schema: { type: object, maxProperties: 100 }
    OptionalJsonObject:
      required: false
      content:
        application/json:
          schema: { type: object, maxProperties: 100 }
  schemas:
    PlatformConfig:
      type: object
      additionalProperties: true
      required: [features, photos]
      properties:
        features:
          type: object
          additionalProperties: true
          required: [photoAttachments]
          properties:
            photoAttachments: { type: boolean, const: true }
        photos:
          type: object
          additionalProperties: false
          required: [maxUploadBytes, maxPerCommunication, captionMaxLength, acceptedMimeTypes]
          properties:
            maxUploadBytes: { type: integer, const: 8388608 }
            maxPerCommunication: { type: integer, const: 5 }
            captionMaxLength: { type: integer, const: 500 }
            acceptedMimeTypes:
              type: array
              minItems: 3
              maxItems: 3
              uniqueItems: true
              items: { type: string, enum: [image/jpeg, image/png, image/webp] }
    PhotoUploadInput:
      type: object
      additionalProperties: false
      required: [file, clientMessageId]
      properties:
        file:
          type: string
          format: binary
          description: Genau eine JPEG-, PNG- oder WebP-Datei; maximal 8388608 Bytes.
        caption:
          type: string
          maxLength: 500
          description: Optionaler Hinweis zum Ablageort.
        clientMessageId:
          type: string
          minLength: 16
          maxLength: 120
          pattern: "^[A-Za-z0-9_.:-]+$"
          description: Stabile Deduplizierungs-ID für sichere Wiederholungen.
    PhotoAttachment:
      type: object
      additionalProperties: false
      required: [id, kind, mimeType, byteSize, width, height, thumbnailUrl, contentUrl]
      properties:
        id: { type: string, minLength: 1, maxLength: 180 }
        kind: { type: string, const: image }
        mimeType: { type: string, const: image/webp }
        byteSize: { type: integer, minimum: 1, maximum: 8388608 }
        width: { type: integer, minimum: 1, maximum: 2048 }
        height: { type: integer, minimum: 1, maximum: 2048 }
        thumbnailUrl: { type: string, pattern: "^/platform/v1/media/[^?]+\\?variant=thumbnail$" }
        contentUrl: { type: string, pattern: "^/platform/v1/media/[^?]+\\?variant=display$" }
    PhotoMessage:
      type: object
      additionalProperties: false
      required: [id, communicationId, type, sender, body, createdAt, attachment]
      properties:
        id: { type: string, minLength: 1, maxLength: 180 }
        communicationId: { type: string, minLength: 1, maxLength: 180 }
        type: { type: string, const: photo }
        sender: { type: string, enum: [user, visitor] }
        body: { type: string, maxLength: 500 }
        createdAt: { type: string, format: date-time }
        attachment: { $ref: "#/components/schemas/PhotoAttachment" }
    Health:
      type: object
      additionalProperties: false
      required: [ok, service, apiVersion, serverTime]
      properties:
        ok: { type: boolean, const: true }
        service: { type: string, const: q-api-platform }
        apiVersion: { type: string, const: v1 }
        authentication: { type: string, const: Bearer }
        documentationUrl: { type: string, format: uri }
        serverTime: { type: string, format: date-time }
    QrDesign:
      type: object
      additionalProperties: false
      description: Vollständige normalisierte Gestaltung des druckbaren QR-Schilds.
      required:
        - color
        - shape
        - label
        - instructionText
        - format
        - language
        - frameStyle
        - qrShape
        - cornerStyle
        - colorMode
        - foregroundColor
        - backgroundColor
        - cornerColor
        - frameColor
        - frameTextColor
        - logoMode
        - logoSize
        - logoBackground
        - showDoorName
        - showInstructions
        - showPrivacyHint
        - includeFallbackText
      properties:
        color: { type: string, pattern: "^#[0-9a-fA-F]{6}$", description: Rückwärtskompatible Akzentfarbe. }
        shape: { type: string, enum: [rounded, square, hexagon], description: Rückwärtskompatible Grundform. }
        label: { type: string, minLength: 1, maxLength: 32 }
        instructionText: { type: string, minLength: 1, maxLength: 72 }
        format: { type: string, enum: [sticker_small, medium_sign, large_sign, a4_sheet] }
        language: { type: string, enum: [de, en] }
        frameStyle: { type: string, enum: [none, simple, top, bottom, speech, sign, rounded] }
        qrShape: { type: string, enum: [classic, round, dots, soft, modern] }
        cornerStyle: { type: string, enum: [classic, round, circle, soft] }
        colorMode: { type: string, enum: [black_white, qlingel, custom] }
        foregroundColor: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
        backgroundColor: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
        cornerColor: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
        frameColor: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
        frameTextColor: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
        logoMode: { type: string, enum: [none, qlingel, custom], description: custom verwendet ein bereits über die Eigentümeroberfläche hinterlegtes Logo. }
        logoSize: { type: string, enum: [small, medium, large] }
        logoBackground: { type: string, enum: [white, transparent] }
        showDoorName: { type: boolean }
        showInstructions: { type: boolean }
        showPrivacyHint: { type: boolean }
        includeFallbackText: { type: boolean }
    QrDesignInput:
      type: object
      additionalProperties: false
      description: |
        color, shape und label bleiben für bestehende Integrationen Pflicht.
        Erweiterte Schildoptionen sind optional. Unbekannte Felder, falsche
        Typen und ungültige Enum-, Farb- oder Textwerte werden mit 400 abgewiesen.
      required: [color, shape, label]
      properties:
        color: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
        shape: { type: string, enum: [rounded, square, hexagon] }
        label: { type: string, minLength: 1, maxLength: 32 }
        instructionText: { type: string, minLength: 1, maxLength: 72 }
        format: { type: string, enum: [sticker_small, medium_sign, large_sign, a4_sheet] }
        language: { type: string, enum: [de, en] }
        frameStyle: { type: string, enum: [none, simple, top, bottom, speech, sign, rounded] }
        qrShape: { type: string, enum: [classic, round, dots, soft, modern] }
        cornerStyle: { type: string, enum: [classic, round, circle, soft] }
        colorMode: { type: string, enum: [black_white, qlingel, custom] }
        foregroundColor: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
        backgroundColor: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
        cornerColor: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
        frameColor: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
        frameTextColor: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
        logoMode: { type: string, enum: [none, qlingel, custom], description: custom setzt den Modus für ein bereits vorhandenes eigenes Logo; Logo-Daten sind hier nicht zulässig. }
        logoSize: { type: string, enum: [small, medium, large] }
        logoBackground: { type: string, enum: [white, transparent] }
        showDoorName: { type: boolean }
        showInstructions: { type: boolean }
        showPrivacyHint: { type: boolean }
        includeFallbackText: { type: boolean }
    QrStudio:
      type: object
      additionalProperties: false
      required: [door, design, health]
      properties:
        door: { $ref: "#/components/schemas/Resource" }
        design: { $ref: "#/components/schemas/QrDesign" }
        health: { $ref: "#/components/schemas/Resource" }
    QrDesignResult:
      type: object
      additionalProperties: false
      required: [ok, tokenUnchanged, design, door]
      properties:
        ok: { type: boolean, const: true }
        tokenUnchanged: { type: boolean, const: true }
        design: { $ref: "#/components/schemas/QrDesign" }
        door: { $ref: "#/components/schemas/Resource" }
    Resource: { type: object, additionalProperties: true }
    ResourceList: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Resource" } }
    Ok:
      type: object
      required: [ok]
      properties: { ok: { type: boolean } }
    Error:
      type: object
      additionalProperties: false
      required: [message, code, details]
      properties:
        message: { type: string, maxLength: 500 }
        code: { type: string, maxLength: 120 }
        details:
          type: object
          required: [requestId]
          properties: { requestId: { type: string } }
  responses:
    Resource:
      description: Ressource
      headers: { X-Request-Id: { schema: { type: string } } }
      content: { application/json: { schema: { $ref: "#/components/schemas/Resource" } } }
    ResourceList:
      description: Ressourcenliste
      headers: { X-Request-Id: { schema: { type: string } } }
      content: { application/json: { schema: { $ref: "#/components/schemas/ResourceList" } } }
    Ok:
      description: Aktion abgeschlossen
      content: { application/json: { schema: { $ref: "#/components/schemas/Ok" } } }
    Error:
      description: Fehler
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Unauthorized:
      description: Token fehlt oder ist ungültig
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Forbidden:
      description: Scope, Status oder Zugriffszeit verweigert
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    NotFound:
      description: Ressource im Token-Mandanten nicht gefunden
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Conflict:
      description: Zustands- oder Idempotenzkonflikt
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    RateLimited:
      description: Zugriffslimit erreicht
      headers: { Retry-After: { schema: { type: integer, minimum: 1 } } }
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    PayloadTooLarge:
      description: Multipart-Upload oder Bilddatei überschreitet 8388608 Bytes
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    UnsupportedMediaType:
      description: Multipart-Formular oder Bildformat wird nicht unterstützt
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    UnprocessableEntity:
      description: Bild ist nicht decodierbar oder die Fotofunktion steht für die Kommunikation nicht bereit
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
