openapi: 3.1.0
info:
  title: Turq External API
  version: 1.1.0
  summary: HTTP API for sending data from your own devices and smart-home systems to Turq and for reading the data they need to work.
  description: |
    The Turq External API lets your own devices and automations (for example a
    Home Assistant setup, an ESPHome board, a Shelly relay, Node-RED or a plain
    cURL cron job) write data into your aquariums in Turq and read back what they
    need to work.

    Writing records measurements and dosing events, marks a task as done and
    sends a push notification to your own devices. Reading (version 1.1) returns
    the aquariums of a key, the parameters with their units and allowed ranges,
    the dosing products, the task templates and the latest value of each
    parameter. It returns nothing else: there is no measurement history and no
    other account data.

    ## Reading

    Reading uses `GET` routes. Each of them needs one read scope, and no scope
    includes another:

    - `CONFIG_READ`: structure. Aquariums, parameters with unit, step and
      plausible range, and the names of dosing products and task templates.
    - `STATUS_READ`: state. Adds the target range of a parameter, the remaining
      volume of a dosing product and whether a task is due. These properties are
      only present when the key has this scope.
    - `MEASUREMENTS_READ`: the latest value of each parameter.

    A key can combine read and write scopes, so one device needs only one key.
    Read scopes are never added to an existing key; create a new key for them.
    Reading is open to every secured account, and an aquarium that is read-only
    in the Turq app can still be read, but not written.

    ## Not for control

    Turq values are meant for display and for limiting input on a device. They
    are not a safe basis for controlling equipment such as heaters, pumps,
    dosing pumps or lights. A value may be old, may be entered by hand, may come
    from a lab report or from another integration, and may be a bound such as
    "below 0.05". Every latest value therefore carries `timestamp`, `origin`,
    `operator` and `belowDetection`. Do not let a Turq value switch equipment
    on or off.

    ## Text from users

    Names of aquariums, dosing products and task templates are entered by
    people. Treat them as untrusted text: escape them before you put them into
    HTML, a shell command, a template or a log line. Turq removes control
    characters and shortens long names, but does not remove markup.

    ## Caching

    Read responses carry a weak `ETag`. Send it back in `If-None-Match` to get
    `304 Not Modified` without a body when nothing changed. A `304` still counts
    against the request limits, and it does not make the server cheaper; use it
    to save bandwidth. Responses are marked `Cache-Control: private, no-store`
    and `Vary: Authorization`, and must not be stored in a shared cache. The
    structure of an aquarium rarely changes: read it at start-up and then at
    a long interval. The latest value changes only when someone measures, so a
    short interval does not make it fresher. The standard `Date` response header
    is the server time.

    ## Forward compatibility

    New optional properties, new enum values (for example new scopes or error
    codes) and new routes may be added within `v1`. Clients must ignore
    properties they do not know and must tolerate unknown enum values.

    ## Requirements

    - The Turq account that owns the API key must be a secured account (signed in
      with an e-mail address or a social provider). Guest accounts are excluded.
    - A key writes only into aquariums the account may edit in the Turq app. If an
      aquarium is read-only in the app, it is read-only for the API as well.
      Reading stays possible.
    - The account must have accepted the current terms. After a new version of
      the terms or the privacy notice, keys keep working for a transition period
      (see below).

    ## Authentication

    Create an API key in the Turq app (Settings, Integrations). Send it as a
    bearer token in the `Authorization` header. The key is shown once at
    creation time. Treat it like a password and revoke it in the app if it leaks.
    Each key is bound to a fixed set of aquariums and a fixed set of scopes.
    Only aquariums the account can currently use are listed and accepted; an
    aquarium that was deleted, left by the account or is a demo aquarium is not.

    ## Terms and the transition period

    When Turq publishes new terms or a new privacy notice, the keys of an account
    that had accepted the previous version keep working for a transition period.
    While it runs, every response that passed the consent check carries the header
    `X-Turq-Consent-Deadline`: the end of the transition period as an ISO 8601
    timestamp in UTC. After that moment the API answers with HTTP 403 and the code
    `CONSENT_REQUIRED` until the account owner agrees in the Turq app; after the
    agreement the keys work again immediately. Keys and data are not deleted.
    A client can read the header to warn its user in time. If the header is
    absent, the terms are accepted or no transition period applies. In rare
    cases access lasts longer than announced (for example when the account
    owner declined new terms and keeps a continuation right); the header does
    not change then.

    ## Idempotency

    Every write route accepts an optional `Idempotency-Key` header. Repeating a
    request with the same key and the same body returns the original result
    again (marked with `replayed: true`) instead of writing a second time. Using
    the same key with a different body is rejected. Keys are remembered for a
    limited time.

    ## Limits

    Requests are limited. When a limit is reached the API answers with HTTP 429
    and a `Retry-After` header (in seconds). Reading and writing have separate
    limits and do not use each other up. Measurements and dosing events also
    have a daily allowance per aquarium; write responses for these routes carry
    the headers `X-Turq-Daily-Limit` and `X-Turq-Daily-Remaining` so a client can
    adapt. The concrete values may change over time and are intentionally not
    part of this specification; rely on the headers and on `Retry-After`.

    ## Errors

    Every error uses the same JSON shape with a machine-readable `code`, a
    static human-readable `message` and a `requestId` you can quote when you ask
    for support.

    ## Notes

    Names of third-party products appear only as examples of systems that can
    send HTTP requests. Turq is not affiliated with them.
    This specification makes no promise about uptime or availability.
    Reading can be switched off separately; the read routes then answer with
    `SERVICE_DISABLED` while writing keeps working.
servers:
  - url: https://api.turq.app
    description: Production
security:
  - bearerAuth: []
tags:
  - name: Connection
    description: Check that a key works.
  - name: Writes
    description: Record data in an aquarium.
  - name: Reading
    description: Read the structure and the latest values of an aquarium.
paths:
  /v1/ping:
    post:
      operationId: ping
      tags: [Connection]
      summary: Check an API key
      description: |
        Verifies that the key is valid and active and returns the scopes and the
        IDs of the aquariums the key can use right now. Needs no scope and writes
        nothing. The request needs no body. This route has its own, stricter
        request limit.
      responses:
        '200':
          description: The key is valid.
          headers:
            X-Turq-Consent-Deadline:
              $ref: '#/components/headers/XTurqConsentDeadline'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PingResponse'
              example:
                valid: true
                scopes: [MEASUREMENTS_WRITE, DOSING_WRITE]
                aquariumIds: [3f9a6b52-1c1e-4d57-8a3a-6f2d3a8b9c01]
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ForbiddenAccount'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/ServiceDisabled'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/aquariums/{aquariumId}/measurements:
    post:
      operationId: createMeasurement
      tags: [Writes]
      summary: Record water values
      description: |
        Records one measurement with one or more parameter values at a point in
        time. Required scope: `MEASUREMENTS_WRITE`.

        Parameter keys are the Turq parameter keys, for example `ph`, `kh`, `gh`,
        `temperature`, `no3`, `po4`, `salinity`. Values are plain numbers in the
        unit Turq uses for that parameter. Values outside the plausible range of
        a parameter are rejected.

        Water values change slowly. A fixed interval of several minutes is
        usually enough; do not send on every sensor reading.

        The daily allowance is counted in individual values, not in requests.
      parameters:
        - $ref: '#/components/parameters/AquariumId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateMeasurementRequest'
            examples:
              ph:
                summary: A single pH value
                value:
                  timestamp: 1761800000000
                  values:
                    ph: 7.2
              several:
                summary: Several values at once
                value:
                  timestamp: 1761800000000
                  values:
                    temperature: 25.4
                    ph: 7.1
                    no3: 12.5
      responses:
        '201':
          description: The measurement was recorded.
          headers:
            X-Turq-Daily-Limit:
              $ref: '#/components/headers/XTurqDailyLimit'
            X-Turq-Daily-Remaining:
              $ref: '#/components/headers/XTurqDailyRemaining'
            X-Turq-Consent-Deadline:
              $ref: '#/components/headers/XTurqConsentDeadline'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreatedResponse'
              examples:
                created:
                  value:
                    id: 0b7e2c5a-6f1d-4b8e-9a3c-2d4f6a8c0e12
                replayed:
                  summary: Repeated request with the same Idempotency-Key
                  value:
                    id: 0b7e2c5a-6f1d-4b8e-9a3c-2d4f6a8c0e12
                    replayed: true
        '400':
          $ref: '#/components/responses/ValidationFailed'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ForbiddenWrite'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '429':
          $ref: '#/components/responses/RateLimitedOrDaily'
        '503':
          $ref: '#/components/responses/ServiceDisabled'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/aquariums/{aquariumId}/dosing-events:
    post:
      operationId: createDosingEvent
      tags: [Writes]
      summary: Record a dosing event
      description: |
        Records that a dosing product was dosed (or refilled or calibrated) in
        the aquarium. Required scope: `DOSING_WRITE`.

        The product must belong to the aquarium in the path. The `type` value
        `manual_dose` records a dose and reduces the remaining volume of the
        product by `amount`; `refill` and `calibration` are statements about the
        remaining volume. If `timestamp` is omitted the server time is used.
      parameters:
        - $ref: '#/components/parameters/AquariumId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateDosingEventRequest'
            examples:
              dose:
                summary: A manual dose
                value:
                  productId: 9b2e5d7c-0a1f-4c3b-8d6e-1f2a3b4c5d6e
                  amount: 2.5
                  type: manual_dose
                  unit: ml
      responses:
        '201':
          description: The dosing event was recorded.
          headers:
            X-Turq-Daily-Limit:
              $ref: '#/components/headers/XTurqDailyLimit'
            X-Turq-Daily-Remaining:
              $ref: '#/components/headers/XTurqDailyRemaining'
            X-Turq-Consent-Deadline:
              $ref: '#/components/headers/XTurqConsentDeadline'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreatedResponse'
              example:
                id: 5d3c1a9e-8b7f-4e6d-a2c4-0f1e2d3c4b5a
        '400':
          $ref: '#/components/responses/ValidationFailed'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ForbiddenWrite'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '429':
          $ref: '#/components/responses/RateLimitedOrDaily'
        '503':
          $ref: '#/components/responses/ServiceDisabled'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/aquariums/{aquariumId}/tasks/complete:
    post:
      operationId: completeTask
      tags: [Writes]
      summary: Mark the current task occurrence as done
      description: |
        Marks the current occurrence of a recurring task as done: today's
        latest occurrence that is already due, otherwise today's next one,
        otherwise the most recent overdue one. Older open occurrences stay open;
        they are never worked off by this route (the Turq app lets people mark a
        backlog as missed). Required scope: `TASKS_WRITE`.

        The task template must belong to the aquarium in the path. If the task
        has no occurrence that can be completed right now, the API answers with
        the error code `NO_DUE_TASK`. This includes a repeated call: once the
        current occurrence is done, a second call does not complete an older
        one but answers `NO_DUE_TASK`. The same holds for two calls at the same
        time: one completes the occurrence, the other gets `NO_DUE_TASK`.
      parameters:
        - $ref: '#/components/parameters/AquariumId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CompleteTaskRequest'
            examples:
              basic:
                summary: Complete a task
                value:
                  templateId: 7c1d2e3f-4a5b-4c6d-8e7f-0a1b2c3d4e5f
              withNote:
                summary: With a note
                value:
                  templateId: 7c1d2e3f-4a5b-4c6d-8e7f-0a1b2c3d4e5f
                  note: Water change done by the automation
      responses:
        '201':
          description: The task occurrence was marked as done.
          headers:
            X-Turq-Consent-Deadline:
              $ref: '#/components/headers/XTurqConsentDeadline'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreatedResponse'
              example:
                id: c4a1e8b2-3d5f-4a7c-9e0b-6f8d1a2b3c4d
        '400':
          $ref: '#/components/responses/ValidationFailed'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ForbiddenWrite'
        '404':
          $ref: '#/components/responses/NotFoundOrNoDueTask'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/ServiceDisabled'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/aquariums/{aquariumId}/notifications:
    post:
      operationId: sendNotification
      tags: [Writes]
      summary: Send a push notification to your own devices
      description: |
        Sends a push notification to the devices of the account that owns the
        key. Other members of the aquarium do not receive it. Required scope:
        `NOTIFICATIONS_WRITE`.

        The title of the push notification is always set by Turq to
        "🔌 <key name> · <aquarium name>", so the message is recognizable as
        coming from an integration. A device cannot change it. The `title` and
        `body` you send appear in the message text below that heading, `title`
        on the first line and `body` on the next. Both are plain text; control
        characters are removed. This route has its own, stricter request limits.
      parameters:
        - $ref: '#/components/parameters/AquariumId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendNotificationRequest'
            examples:
              lowWater:
                summary: A warning
                value:
                  title: Tank
                  body: Water level is low
      responses:
        '200':
          description: The notification was accepted for delivery.
          headers:
            X-Turq-Consent-Deadline:
              $ref: '#/components/headers/XTurqConsentDeadline'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SentResponse'
              examples:
                sent:
                  value:
                    sent: true
                replayed:
                  summary: Repeated request with the same Idempotency-Key
                  value:
                    sent: true
                    replayed: true
        '400':
          $ref: '#/components/responses/ValidationFailed'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ForbiddenWrite'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/ServiceDisabled'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/aquariums:
    get:
      operationId: listAquariums
      tags: [Reading]
      summary: List the aquariums of a key
      description: |
        Returns the aquariums the key is bound to and the account can currently
        use. Required scope: `CONFIG_READ`. `readOnly` is true when the aquarium
        is read-only in the Turq app: it can be read, but writes are rejected.
        This route is not counted as activity of an aquarium.
      responses:
        '200':
          description: The aquariums of the key.
          headers:
            ETag:
              $ref: '#/components/headers/ETag'
            X-Turq-Consent-Deadline:
              $ref: '#/components/headers/XTurqConsentDeadline'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AquariumList'
              example:
                items:
                  - id: 3f9a6b52-1c1e-4d57-8a3a-6f2d3a8b9c01
                    name: Living room reef
                    readOnly: false
        '304':
          $ref: '#/components/responses/NotModified'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ForbiddenRead'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/ServiceDisabled'
        '500':
          $ref: '#/components/responses/InternalError'
      parameters:
        - $ref: '#/components/parameters/IfNoneMatch'
  /v1/aquariums/{aquariumId}/parameters:
    get:
      operationId: listParameters
      tags: [Reading]
      summary: List the parameters of an aquarium
      description: |
        Returns the water parameters a device can enter, with the unit, the input
        step and the plausible range. Use them to limit input on the device before
        you send a value. Required scope: `CONFIG_READ`.

        `unit` is the unit the API expects and returns for the parameter (a plain
        ASCII label, or null). `decimals` is the number of decimal places of
        `step`. `plausibleMin` and `plausibleMax` are hard limits; null means that
        no limit is stored, not that every value is valid. With the scope
        `STATUS_READ` each parameter also has `target`, the target range of this
        aquarium (null when none is set).

        The optional query `parameters` limits the list to a comma separated list
        of parameter keys. Unknown or inactive keys are ignored. A malformed list
        is rejected with `VALIDATION_FAILED`.
      parameters:
        - $ref: '#/components/parameters/AquariumId'
        - $ref: '#/components/parameters/ParametersFilter'
        - $ref: '#/components/parameters/IfNoneMatch'
      responses:
        '200':
          description: The parameters.
          headers:
            ETag:
              $ref: '#/components/headers/ETag'
            X-Turq-Consent-Deadline:
              $ref: '#/components/headers/XTurqConsentDeadline'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ParameterList'
              example:
                items:
                  - key: ph
                    unit: null
                    decimals: 1
                    step: 0.1
                    plausibleMin: null
                    plausibleMax: null
                    target:
                      min: 8.0
                      max: 8.4
        '304':
          $ref: '#/components/responses/NotModified'
        '400':
          $ref: '#/components/responses/ValidationFailed'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ForbiddenRead'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/ServiceDisabled'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/aquariums/{aquariumId}/dosing-products:
    get:
      operationId: listDosingProducts
      tags: [Reading]
      summary: List the dosing products of an aquarium
      description: |
        Returns the dosing products with the IDs you need for the dosing route.
        Required scope: `CONFIG_READ`. With the scope `STATUS_READ` each product
        also has `remainingVolume`, the remaining volume as the app calculates
        it (null when unknown). It changes only a few times a day.
      parameters:
        - $ref: '#/components/parameters/AquariumId'
        - $ref: '#/components/parameters/IfNoneMatch'
      responses:
        '200':
          description: The dosing products.
          headers:
            ETag:
              $ref: '#/components/headers/ETag'
            X-Turq-Consent-Deadline:
              $ref: '#/components/headers/XTurqConsentDeadline'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DosingProductList'
              example:
                items:
                  - id: 9b2e5d7c-0a1f-4c3b-8d6e-1f2a3b4c5d6e
                    name: Alkalinity
                    unit: ml
                    remainingVolume: 412.5
        '304':
          $ref: '#/components/responses/NotModified'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ForbiddenRead'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/ServiceDisabled'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/aquariums/{aquariumId}/task-templates:
    get:
      operationId: listTaskTemplates
      tags: [Reading]
      summary: List the task templates of an aquarium
      description: |
        Returns the active recurring tasks with the IDs you need for the task
        route. Required scope: `CONFIG_READ`. With the scope `STATUS_READ` each
        template also has `dueNow` (true when the task route can complete an
        occurrence right now) and `nextDueAt` (Unix time in milliseconds of the
        occurrence the task route would complete now, otherwise of the next one
        that is not done yet, starting tomorrow, or null).

        When many templates have to be calculated at once, the server limits the
        work per request. The two status properties can then be missing for some
        templates. A missing property means unknown, not false: ask again later
        or use the task route, which answers `NO_DUE_TASK` when nothing is due.
      parameters:
        - $ref: '#/components/parameters/AquariumId'
        - $ref: '#/components/parameters/IfNoneMatch'
      responses:
        '200':
          description: The task templates.
          headers:
            ETag:
              $ref: '#/components/headers/ETag'
            X-Turq-Consent-Deadline:
              $ref: '#/components/headers/XTurqConsentDeadline'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskTemplateList'
              example:
                items:
                  - id: 7c1d2e3f-4a5b-4c6d-8e7f-0a1b2c3d4e5f
                    name: Water change
                    dueNow: true
                    nextDueAt: 1761800000000
        '304':
          $ref: '#/components/responses/NotModified'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ForbiddenRead'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/ServiceDisabled'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/aquariums/{aquariumId}/measurements/latest:
    get:
      operationId: getLatestMeasurements
      tags: [Reading]
      summary: Get the latest value of each parameter
      description: |
        Returns, for each parameter that has a value in the aquarium, the most
        recent one, as a map from parameter key to value. A parameter without a
        value is missing from the map. Required scope: `MEASUREMENTS_READ`.

        `value` is null when the entry is a bound such as "below 0.05"; then
        `operator` is `<` or `>`, and `belowDetection` is true when the value was
        below the detection limit. `timestamp` is the time the writer gave for
        the measurement (Unix time in milliseconds), not the time of arrival.
        `origin` tells where the value came from: `app` (entered in the app),
        `lab` (lab report), `api` (an integration), `assistant` (the Qualy
        assistant) or `other`. `viaApi` is true when it was written with an API
        key.

        The optional query `parameters` limits the map to a comma separated list
        of parameter keys. Unknown or inactive keys are ignored. A malformed list
        is rejected with `VALIDATION_FAILED`. The latest value changes only when
        someone measures; do not ask for it more often than your use needs.
      parameters:
        - $ref: '#/components/parameters/AquariumId'
        - $ref: '#/components/parameters/ParametersFilter'
        - $ref: '#/components/parameters/IfNoneMatch'
      responses:
        '200':
          description: The latest values.
          headers:
            ETag:
              $ref: '#/components/headers/ETag'
            X-Turq-Consent-Deadline:
              $ref: '#/components/headers/XTurqConsentDeadline'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LatestMeasurements'
              examples:
                values:
                  summary: A hand-entered pH value and a bound from a lab report
                  value:
                    values:
                      ph:
                        value: 8.2
                        operator: null
                        belowDetection: false
                        unit: null
                        timestamp: 1761800000000
                        origin: app
                        viaApi: false
                      po4:
                        value: null
                        operator: '<'
                        belowDetection: true
                        unit: mg/l
                        timestamp: 1761700000000
                        origin: lab
                        viaApi: false
        '304':
          $ref: '#/components/responses/NotModified'
        '400':
          $ref: '#/components/responses/ValidationFailed'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ForbiddenRead'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/ServiceDisabled'
        '500':
          $ref: '#/components/responses/InternalError'
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: turq_sk_<token>
      description: |
        API key created in the Turq app. Send it as `Authorization: Bearer turq_sk_...`.
  parameters:
    AquariumId:
      name: aquariumId
      in: path
      required: true
      description: ID of the aquarium. The key must be bound to it.
      schema:
        type: string
        format: uuid
      example: 3f9a6b52-1c1e-4d57-8a3a-6f2d3a8b9c01
    IfNoneMatch:
      name: If-None-Match
      in: header
      required: false
      description: |
        The `ETag` of an earlier response. When nothing changed, the API answers
        `304 Not Modified` without a body.
      schema:
        type: string
    ParametersFilter:
      name: parameters
      in: query
      required: false
      description: |
        Comma separated parameter keys, for example `kh,ca`. One single value:
        the array form `parameters[]=kh` and a repeated `parameters` are rejected.
        Keys use lower-case letters, digits and underscores only. Unknown or
        inactive keys are ignored.
      schema:
        type: string
        maxLength: 2550
      example: kh,ca
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Optional unique value (for example a UUID) chosen by the client. A repeat
        of the same request with the same key and body does not write again.
      schema:
        type: string
      example: 6f1c2d3e-4a5b-4c6d-8e7f-1a2b3c4d5e6f
  headers:
    XTurqDailyLimit:
      description: Daily allowance of this kind for the aquarium (current UTC day).
      schema:
        type: integer
    XTurqDailyRemaining:
      description: What is left of the daily allowance after this request.
      schema:
        type: integer
    RetryAfter:
      description: Seconds to wait before the request may be repeated.
      schema:
        type: integer
    ETag:
      description: |
        Weak entity tag of the response. Send it back in `If-None-Match` to get
        `304 Not Modified` when nothing changed.
      schema:
        type: string
      example: W/"1c2-aBcDeFgHiJkLmNoPqRsTuVwXyZ"
    XTurqConsentDeadline:
      description: |
        Present only during the transition period after new terms or a new
        privacy notice: the end of the transition period as an ISO 8601
        timestamp in UTC. After this moment the API answers with 403
        `CONSENT_REQUIRED` until the account owner agrees in the Turq app.
      schema:
        type: string
        format: date-time
      example: '2026-12-03T12:00:00.000Z'
  schemas:
    ErrorResponse:
      type: object
      required: [error]
      additionalProperties: false
      properties:
        error:
          type: object
          required: [code, message, requestId]
          additionalProperties: false
          properties:
            code:
              type: string
              description: Stable machine-readable error code.
              enum:
                - UNAUTHORIZED
                - ACCOUNT_NOT_SECURED
                - CONSENT_REQUIRED
                - API_KEY_SUSPENDED
                - AQUARIUM_LOCKED
                - DAILY_LIMIT_REACHED
                - IDEMPOTENCY_IN_PROGRESS
                - SCOPE_DENIED
                - AQUARIUM_NOT_ALLOWED
                - VALIDATION_FAILED
                - IDEMPOTENCY_KEY_REUSED
                - RATE_LIMITED
                - CONFLICT
                - NOT_FOUND
                - NO_DUE_TASK
                - SERVICE_DISABLED
                - INTERNAL_ERROR
            message:
              type: string
              description: Human-readable text. Do not parse it; use `code`.
            requestId:
              type: string
              description: Identifier of this request. Quote it when you contact support.
    PingResponse:
      type: object
      required: [valid, scopes, aquariumIds]
      properties:
        valid:
          type: boolean
        scopes:
          type: array
          items:
            $ref: '#/components/schemas/Scope'
        aquariumIds:
          type: array
          description: Aquariums the key is bound to and the account can currently use.
          items:
            type: string
            format: uuid
    Scope:
      type: string
      enum:
        - MEASUREMENTS_WRITE
        - DOSING_WRITE
        - TASKS_WRITE
        - NOTIFICATIONS_WRITE
        - CONFIG_READ
        - STATUS_READ
        - MEASUREMENTS_READ
    Aquarium:
      type: object
      required: [id, name, readOnly]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          description: Name entered by the user. Untrusted text, shortened.
        readOnly:
          type: boolean
          description: True when the aquarium is read-only in the Turq app. It can be read, not written.
    AquariumList:
      type: object
      required: [items]
      properties:
        items:
          type: array
          maxItems: 50
          items:
            $ref: '#/components/schemas/Aquarium'
    Parameter:
      type: object
      required: [key, unit, decimals, step, plausibleMin, plausibleMax]
      properties:
        key:
          type: string
          description: Parameter key, as used in the measurement route.
        unit:
          type: [string, 'null']
          description: Unit of the parameter as a plain ASCII label; null when it has none.
        decimals:
          type: integer
          description: Number of decimal places of `step`.
        step:
          type: number
          exclusiveMinimum: 0
          description: Input step in the unit of the parameter.
        plausibleMin:
          type: [number, 'null']
          description: Hard lower limit; null when none is stored.
        plausibleMax:
          type: [number, 'null']
          description: Hard upper limit; null when none is stored.
        target:
          type: [object, 'null']
          description: Target range of the aquarium. Only with the scope `STATUS_READ`; null when none is set.
          required: [min, max]
          properties:
            min:
              type: [number, 'null']
            max:
              type: [number, 'null']
    ParameterList:
      type: object
      required: [items]
      properties:
        items:
          type: array
          maxItems: 200
          items:
            $ref: '#/components/schemas/Parameter'
    DosingProduct:
      type: object
      required: [id, name, unit]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          description: Name entered by the user or taken from the catalog. Untrusted text, shortened.
        unit:
          type: string
          description: Unit label of the product, for example `ml`. Untrusted text, shortened.
        remainingVolume:
          type: [number, 'null']
          description: Remaining volume as the app calculates it. Only with the scope `STATUS_READ`.
    DosingProductList:
      type: object
      required: [items]
      properties:
        items:
          type: array
          maxItems: 200
          items:
            $ref: '#/components/schemas/DosingProduct'
    TaskTemplate:
      type: object
      required: [id, name]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          description: Title entered by the user. Untrusted text, shortened.
        dueNow:
          type: boolean
          description: True when the task route can complete an occurrence right now. Only with the scope `STATUS_READ`; can be missing (unknown) when the server limited the work of the request.
        nextDueAt:
          type: [integer, 'null']
          description: Unix time in milliseconds of the occurrence the task route would complete now, otherwise of the next occurrence that is not done yet, starting tomorrow; null when none. Only with the scope `STATUS_READ`; can be missing (unknown) when the server limited the work of the request.
    TaskTemplateList:
      type: object
      required: [items]
      properties:
        items:
          type: array
          maxItems: 200
          items:
            $ref: '#/components/schemas/TaskTemplate'
    LatestValue:
      type: object
      required: [value, operator, belowDetection, unit, timestamp, origin, viaApi]
      properties:
        value:
          type: [number, 'null']
          description: The value; null when the entry is a bound (see `operator`).
        operator:
          type: [string, 'null']
          enum: ['<', '>', null]
          description: '`<` or `>` for a bound such as "below 0.05", otherwise null.'
        belowDetection:
          type: boolean
          description: True when the value was below the detection limit.
        unit:
          type: [string, 'null']
          description: Unit of the parameter; null when it has none.
        timestamp:
          type: integer
          description: Time of the measurement as the writer gave it, Unix time in milliseconds. Not the time of arrival.
        origin:
          type: string
          enum: [app, lab, api, assistant, other]
          description: Where the value came from.
        viaApi:
          type: boolean
          description: True when the value was written with an API key.
    LatestMeasurements:
      type: object
      required: [values]
      properties:
        values:
          type: object
          description: Parameter key to latest value. A parameter without a value is missing.
          additionalProperties:
            $ref: '#/components/schemas/LatestValue'
    CreatedResponse:
      type: object
      required: [id]
      properties:
        id:
          type: string
          format: uuid
          description: ID of the created record.
        replayed:
          type: boolean
          description: Present and true when the response repeats an earlier request with the same Idempotency-Key.
    SentResponse:
      type: object
      required: [sent]
      properties:
        sent:
          type: boolean
        replayed:
          type: boolean
          description: Present and true when the response repeats an earlier request with the same Idempotency-Key.
    CreateMeasurementRequest:
      type: object
      required: [timestamp, values]
      additionalProperties: false
      properties:
        timestamp:
          type: integer
          description: |
            Time of the measurement as Unix time in milliseconds. Not earlier
            than 2000-01-01 and not noticeably in the future.
        values:
          type: object
          description: |
            Parameter key to numeric value. Keys must be non-empty and short,
            the number of entries is limited, and values must be finite numbers.
          maxProperties: 200
          propertyNames:
            minLength: 1
            maxLength: 50
          additionalProperties:
            type: number
    CreateDosingEventRequest:
      type: object
      required: [productId, amount, type]
      additionalProperties: false
      properties:
        productId:
          type: string
          format: uuid
          description: Dosing product of the aquarium in the path.
        amount:
          type: number
          description: Amount in the unit of the product.
        type:
          type: string
          description: |
            Kind of event. `manual_dose`, `refill` and `calibration` change the
            remaining volume of the product.
          examples: [manual_dose, refill, calibration]
        unit:
          type: string
          maxLength: 10
          description: Unit label, for example `ml`.
        notes:
          type: string
          maxLength: 500
        timestamp:
          type: number
          description: Unix time in milliseconds. Defaults to the server time.
    CompleteTaskRequest:
      type: object
      required: [templateId]
      additionalProperties: false
      properties:
        templateId:
          type: string
          format: uuid
          description: Task template of the aquarium in the path.
        note:
          type: string
          maxLength: 500
        metadata:
          type: [object, 'null']
          description: Free-form JSON object that is stored with the completion. Limited in size.
    SendNotificationRequest:
      type: object
      required: [title, body]
      additionalProperties: false
      properties:
        title:
          type: string
          maxLength: 80
          description: First line of the message text. It is not the title of the push notification.
        body:
          type: string
          maxLength: 240
          description: Second line of the message text.
  responses:
    Unauthorized:
      description: The key is missing, malformed, unknown or revoked.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: UNAUTHORIZED
              message: Invalid or missing API key
              requestId: 8f14e45f-ceea-467a-9b5e-3c2d1a0b9e8f
    ForbiddenAccount:
      description: |
        The request is not allowed. Possible codes: `ACCOUNT_NOT_SECURED` (the
        owning account is a guest account), `CONSENT_REQUIRED` (the owning
        account has not accepted the current terms and the transition period after
        the new version has ended), `API_KEY_SUSPENDED` (the key or its account
        is suspended).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: ACCOUNT_NOT_SECURED
              message: The account owning this API key is not secured
              requestId: 8f14e45f-ceea-467a-9b5e-3c2d1a0b9e8f
    ForbiddenWrite:
      description: |
        The request is not allowed. Possible codes: `ACCOUNT_NOT_SECURED`,
        `CONSENT_REQUIRED` (after the transition period), `API_KEY_SUSPENDED`, `SCOPE_DENIED` (the key lacks
        the scope of this route), `AQUARIUM_NOT_ALLOWED` (the key is not bound
        to this aquarium, or the account may no longer use it),
        `AQUARIUM_LOCKED` (the aquarium is read-only).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: AQUARIUM_LOCKED
              message: This aquarium is read-only
              requestId: 8f14e45f-ceea-467a-9b5e-3c2d1a0b9e8f
    NotModified:
      description: |
        Nothing changed since the response with the `ETag` you sent in
        `If-None-Match`. No body. The `304` still counts against the request limits.
      headers:
        ETag:
          $ref: '#/components/headers/ETag'
    ForbiddenRead:
      description: |
        The request is not allowed. Possible codes: `ACCOUNT_NOT_SECURED`,
        `CONSENT_REQUIRED` (after the transition period), `API_KEY_SUSPENDED`,
        `SCOPE_DENIED` (the key lacks the read scope of this route),
        `AQUARIUM_NOT_ALLOWED` (the key is not bound to this aquarium, or the
        account may no longer use it). A read-only aquarium is not an error here.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: SCOPE_DENIED
              message: This API key is not allowed to perform this action
              requestId: 8f14e45f-ceea-467a-9b5e-3c2d1a0b9e8f
    ValidationFailed:
      description: The path, the query or the body is invalid. The message lists what is wrong.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: VALIDATION_FAILED
              message: values must be an object with finite numeric values
              requestId: 8f14e45f-ceea-467a-9b5e-3c2d1a0b9e8f
    NotFound:
      description: A referenced resource, for example the dosing product, was not found in this aquarium.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: NOT_FOUND
              message: Resource not found
              requestId: 8f14e45f-ceea-467a-9b5e-3c2d1a0b9e8f
    NotFoundOrNoDueTask:
      description: |
        `NOT_FOUND` (the task template was not found in this aquarium) or
        `NO_DUE_TASK` (there is no occurrence that can be completed right now).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: NO_DUE_TASK
              message: No completable task occurrence
              requestId: 8f14e45f-ceea-467a-9b5e-3c2d1a0b9e8f
    IdempotencyConflict:
      description: |
        `IDEMPOTENCY_KEY_REUSED` (the key was already used with a different
        body), `IDEMPOTENCY_IN_PROGRESS` (the first request with this key is
        still running; repeat after `Retry-After`) or `CONFLICT`.
      headers:
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: IDEMPOTENCY_KEY_REUSED
              message: Idempotency key was already used with a different request body
              requestId: 8f14e45f-ceea-467a-9b5e-3c2d1a0b9e8f
    RateLimited:
      description: Too many requests. Wait `Retry-After` seconds before you retry.
      headers:
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: RATE_LIMITED
              message: Rate limit exceeded
              requestId: 8f14e45f-ceea-467a-9b5e-3c2d1a0b9e8f
    RateLimitedOrDaily:
      description: |
        `RATE_LIMITED` (too many requests) or `DAILY_LIMIT_REACHED` (the daily
        allowance of this aquarium is used up). Wait `Retry-After` seconds. For
        the daily allowance the wait ends at the start of the next UTC day.
      headers:
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
        X-Turq-Daily-Limit:
          $ref: '#/components/headers/XTurqDailyLimit'
        X-Turq-Daily-Remaining:
          $ref: '#/components/headers/XTurqDailyRemaining'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: DAILY_LIMIT_REACHED
              message: Daily limit for this aquarium reached
              requestId: 8f14e45f-ceea-467a-9b5e-3c2d1a0b9e8f
    ServiceDisabled:
      description: The API is temporarily switched off.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: SERVICE_DISABLED
              message: External API is temporarily disabled
              requestId: 8f14e45f-ceea-467a-9b5e-3c2d1a0b9e8f
    InternalError:
      description: Unexpected server error.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: INTERNAL_ERROR
              message: Internal server error
              requestId: 8f14e45f-ceea-467a-9b5e-3c2d1a0b9e8f
