openapi: "3.0.3"

info:
  title: Yak Link API
  version: "1.0.0"
  description: |
    The Yak Link API lets you create, manage, and track shortened URLs programmatically.

    **Authentication**
    All endpoints require an API key, passed either as the `apiKey` parameter or as an
    `Authorization: Bearer <key>` header. Obtain your key from the *API Keys* section of
    your Yak Link account settings.

    **Endpoints**
    Besides `POST /v1/shorten`, links can be managed RESTfully under `/v1/links`
    (list, create, fetch, update, delete).

    **Versioning**
    All API endpoints are prefixed with `/v1/`. Breaking changes will be introduced
    under a new version prefix (e.g. `/v2/`).

    **Rate limiting / quotas**
    Each API key is subject to a monthly link-creation quota determined by your plan.
    Exceeding the quota returns HTTP 402.

servers:
  - url: https://yaklinks.com
    description: Production API host (returns short links on the yak.link domain)

tags:
  - name: Links
    description: Create and manage shortened links
  - name: Webhooks
    description: Manage outbound webhook subscriptions

paths:

  /v1/shorten:
    post:
      tags: [Links]
      summary: Create a short link
      operationId: createShortLink
      description: |
        Shortens a destination URL and returns the resulting short link.

        Accepts `application/x-www-form-urlencoded` (typical for form POSTs and Zapier
        actions) or query parameters (GET).

        If `slug` is omitted a random 8-character base-62 slug is generated.
        If `domain` is omitted `yak.link` is used.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: "#/components/schemas/ShortenRequest"
            examples:
              minimal:
                summary: Minimal — URL only
                value:
                  apiKey: "your_api_key"
                  url: "https://example.com/my-very-long-page-url"
              with_domain:
                summary: Custom domain + slug
                value:
                  apiKey: "your_api_key"
                  url: "https://example.com/my-very-long-page-url"
                  domain: "my.brand"
                  slug: "summer-sale"
      responses:
        "200":
          description: Short link created successfully
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ShortenSuccess"
              example:
                ok: true
                short_url: "https://yak.link/abc12345"
                slug: "abc12345"
                domain: "yak.link"
                long_url: "https://example.com/my-very-long-page-url"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/QuotaExceeded"
        "409":
          $ref: "#/components/responses/SlugConflict"
        "422":
          $ref: "#/components/responses/DomainNotFound"
        "500":
          $ref: "#/components/responses/InternalError"

    get:
      tags: [Links]
      summary: Create a short link (GET)
      operationId: createShortLinkGet
      description: |
        Identical to `POST /v1/shorten` but accepts all parameters as query-string
        arguments. Useful for simple webhook triggers or browser-based integrations
        where a POST body is inconvenient.

        **Note:** Avoid embedding sensitive API keys in server logs. Prefer POST for
        production use.
      parameters:
        - $ref: "#/components/parameters/apiKey"
        - name: url
          in: query
          required: true
          description: Destination URL to shorten (must start with `http://` or `https://`)
          schema:
            type: string
            format: uri
            example: "https://example.com/my-very-long-page-url"
        - name: domain
          in: query
          required: false
          description: |
            Domain to host the short link. Use `yak.link` (default), `yakl.ink`, or any
            custom domain registered to your account.
          schema:
            type: string
            default: "yak.link"
            example: "yak.link"
        - name: slug
          in: query
          required: false
          description: |
            Custom slug (the short code after the domain). Must be unique per domain.
            Allowed characters: letters, digits, hyphens (`-`), underscores (`_`).
            If omitted, an 8-character random slug is generated.
          schema:
            type: string
            pattern: "^[A-Za-z0-9_-]+$"
            example: "summer-sale"
      responses:
        "200":
          description: Short link created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ShortenSuccess"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/QuotaExceeded"
        "409":
          $ref: "#/components/responses/SlugConflict"
        "422":
          $ref: "#/components/responses/DomainNotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/links:
    get:
      tags: [Links]
      summary: List links
      operationId: listLinks
      description: |
        Returns a paginated list of the links owned by the account the API key
        belongs to, newest first, each with its total click count.
      parameters:
        - $ref: "#/components/parameters/apiKey"
        - name: page
          in: query
          required: false
          description: 1-based page number.
          schema: { type: integer, minimum: 1, default: 1 }
        - name: page_size
          in: query
          required: false
          description: Links per page (max 200).
          schema: { type: integer, minimum: 1, maximum: 200, default: 25 }
        - name: search
          in: query
          required: false
          description: Case-insensitive filter over slug, destination URL and domain.
          schema: { type: string }
      responses:
        "200":
          description: A page of links
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LinkList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

    post:
      tags: [Links]
      summary: Create a link
      operationId: createLink
      description: |
        RESTful equivalent of `POST /v1/shorten`, additionally accepting the
        social-preview and mobile deep-link fields. Returns `201` with the created
        link. Metered against the account's monthly plan quota.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: "#/components/schemas/LinkWriteRequest"
      responses:
        "201":
          description: Link created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ShortenSuccess"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/QuotaExceeded" }
        "409": { $ref: "#/components/responses/SlugConflict" }
        "422": { $ref: "#/components/responses/DomainNotFound" }
        "500": { $ref: "#/components/responses/InternalError" }

  /v1/links/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Numeric link id (as returned by create / list).
        schema: { type: integer, format: int64 }
    get:
      tags: [Links]
      summary: Fetch a link
      operationId: getLink
      parameters:
        - $ref: "#/components/parameters/apiKey"
      responses:
        "200":
          description: The link
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  link: { $ref: "#/components/schemas/Link" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      tags: [Links]
      summary: Update a link
      operationId: updateLink
      description: |
        Partial update — only the fields present in the request body are changed;
        omitted fields are left untouched. Editing `url` or `slug` takes effect on
        redirects immediately (the redirect cache is invalidated). `PUT` is accepted
        as an alias.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: "#/components/schemas/LinkUpdateRequest"
      responses:
        "200":
          description: The updated link
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  link: { $ref: "#/components/schemas/Link" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/SlugConflict" }
        "500": { $ref: "#/components/responses/InternalError" }
    put:
      tags: [Links]
      summary: Update a link (alias for PATCH)
      operationId: replaceLink
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: "#/components/schemas/LinkUpdateRequest"
      responses:
        "200":
          description: The updated link
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  link: { $ref: "#/components/schemas/Link" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/SlugConflict" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      tags: [Links]
      summary: Delete a link
      operationId: deleteLink
      description: |
        Permanently deletes the link, its campaign membership and its stored clicks,
        and stops the slug from resolving (subsequent visits return 404).
      parameters:
        - $ref: "#/components/parameters/apiKey"
      responses:
        "200":
          description: Link deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  deleted: { type: integer, format: int64, example: 123 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }

  /v1/links/{id}/rules:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: integer, format: int64 }
    get:
      tags: [Links]
      summary: List a link's smart-routing rules
      operationId: listRoutingRules
      parameters:
        - $ref: "#/components/parameters/apiKey"
      responses:
        "200":
          description: Rules
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  rules: { type: array, items: { $ref: "#/components/schemas/RoutingRule" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Links]
      summary: Add a smart-routing rule
      operationId: addRoutingRule
      description: >
        Rules are evaluated geo → device → weighted A/B; the first match wins,
        otherwise the link's normal destination is used.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [apiKey, kind, destination]
              properties:
                apiKey:      { type: string }
                kind:        { type: string, enum: [geo, device, ab] }
                match_value: { type: string, description: "Country code (geo) or ios|android|mobile|desktop|tablet (device)" }
                destination: { type: string, format: uri }
                weight:      { type: integer, default: 1, description: "Relative weight for ab rules" }
      responses:
        "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/RoutingRule" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/links/{id}/rules/{ruleId}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: integer, format: int64 }
      - name: ruleId
        in: path
        required: true
        schema: { type: integer, format: int64 }
    delete:
      tags: [Links]
      summary: Delete a smart-routing rule
      operationId: deleteRoutingRule
      parameters:
        - $ref: "#/components/parameters/apiKey"
      responses:
        "200": { description: Deleted, content: { application/json: { schema: { type: object, properties: { ok: { type: boolean }, deleted: { type: integer } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/conversions:
    post:
      tags: [Links]
      summary: Record a conversion
      operationId: recordConversion
      description: >
        Attribute a conversion (signup, purchase, …) to a link, server-side. Identify
        the link by `link_id` or by `slug` (+ optional `domain`). Optionally include
        revenue (`value`/`currency`) and your own `external_id` for dedup. Fires the
        `conversion.recorded` webhook. A convenience 1x1 pixel is also available at
        `GET /c.gif?link=<id>&event=<name>` (public, best-effort, spoofable).
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [apiKey]
              properties:
                apiKey:      { type: string }
                link_id:     { type: integer, format: int64 }
                slug:        { type: string }
                domain:      { type: string, default: "yak.link" }
                event:       { type: string, default: "conversion" }
                value:       { type: number, description: "Optional revenue amount" }
                currency:    { type: string, example: "USD" }
                external_id: { type: string, description: "Your order/dedup id" }
      responses:
        "201":
          description: Recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  id: { type: integer, format: int64 }
                  link_id: { type: integer, format: int64 }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }

  /v1/webhooks:
    get:
      tags: [Webhooks]
      summary: List webhook subscriptions
      operationId: listWebhooks
      description: Returns the account's webhook subscriptions. The signing secret is never returned.
      parameters:
        - $ref: "#/components/parameters/apiKey"
      responses:
        "200":
          description: Subscriptions
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  webhooks:
                    type: array
                    items: { $ref: "#/components/schemas/Webhook" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      tags: [Webhooks]
      summary: Create a webhook subscription
      operationId: createWebhook
      description: >
        Registers an endpoint. The response includes the HMAC signing `secret`
        **once** — store it; it is never returned again. Deliveries are POSTed with
        `X-Yak-Event` and `X-Yak-Signature: sha256=<hmac>` headers. Private/loopback
        target URLs are rejected.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [apiKey, target_url]
              properties:
                apiKey:      { type: string }
                target_url:  { type: string, format: uri }
                event_types: { type: string, description: "CSV of event keys or * (default)", example: "link.created,link.clicked" }
      responses:
        "201":
          description: Created (includes the one-time secret)
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Webhook"
                  - type: object
                    properties:
                      ok:     { type: boolean, example: true }
                      secret: { type: string, example: "a1b2c3..." }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/InternalError" }

  /v1/webhooks/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: integer, format: int64 }
    patch:
      tags: [Webhooks]
      summary: Update a webhook subscription
      operationId: updateWebhook
      description: Update target_url, event_types, and/or active (partial).
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [apiKey]
              properties:
                apiKey:      { type: string }
                target_url:  { type: string, format: uri }
                event_types: { type: string }
                active:      { type: boolean }
      responses:
        "200": { description: Updated, content: { application/json: { schema: { type: object, properties: { ok: { type: boolean }, id: { type: integer } } } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      tags: [Webhooks]
      summary: Delete a webhook subscription
      operationId: deleteWebhook
      parameters:
        - $ref: "#/components/parameters/apiKey"
      responses:
        "200": { description: Deleted, content: { application/json: { schema: { type: object, properties: { ok: { type: boolean }, deleted: { type: integer } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }

components:

  schemas:

    ShortenRequest:
      type: object
      required:
        - apiKey
        - url
      properties:
        apiKey:
          type: string
          description: Your Yak Link API key
          example: "aBcDeFgHiJkLmNoPqRsTuVw"
        url:
          type: string
          format: uri
          description: Destination URL to shorten (must start with `http://` or `https://`)
          example: "https://example.com/my-very-long-page-url"
        domain:
          type: string
          description: |
            Domain to host the short link. Accepted values:
            - `yak.link` (default)
            - `yakl.ink`
            - Any custom domain registered to your account
          default: "yak.link"
          example: "yak.link"
        slug:
          type: string
          description: |
            Custom slug (the short code). Must be unique per domain.
            Allowed characters: letters, digits, hyphens, underscores.
            Auto-generated (8 chars, base-62) if omitted.
          pattern: "^[A-Za-z0-9_-]+$"
          example: "summer-sale"

    ShortenSuccess:
      type: object
      properties:
        ok:
          type: boolean
          description: Always `true` on success
          example: true
        short_url:
          type: string
          format: uri
          description: The complete shortened URL, ready to share
          example: "https://yak.link/abc12345"
        slug:
          type: string
          description: The slug portion of the short URL
          example: "abc12345"
        domain:
          type: string
          description: The domain used for the short link
          example: "yak.link"
        long_url:
          type: string
          format: uri
          description: The original destination URL
          example: "https://example.com/my-very-long-page-url"

    Link:
      type: object
      properties:
        id:             { type: integer, format: int64, example: 123 }
        short_url:      { type: string, format: uri, example: "https://yak.link/abc12345" }
        domain:         { type: string, example: "yak.link" }
        slug:           { type: string, example: "abc12345" }
        long_url:       { type: string, format: uri, example: "https://example.com/page" }
        og_title:       { type: string, example: "" }
        og_description: { type: string, example: "" }
        og_image_url:   { type: string, example: "" }
        ios_url:        { type: string, example: "" }
        android_url:    { type: string, example: "" }
        expires_at:     { type: integer, format: int64, description: "Unix seconds; 0 = never expires", example: 0 }
        activates_at:   { type: integer, format: int64, description: "Unix seconds; 0 = live immediately", example: 0 }
        redirect_type:  { type: integer, enum: [301, 302], example: 302 }
        password_protected: { type: boolean, example: false }
        tags:           { type: string, description: "Comma-separated tags", example: "marketing,q3" }
        folder:         { type: string, description: "Optional folder label", example: "Q3 Campaign" }
        retarget_pixels: { type: string, description: "Retargeting pixels: fb:<id>,ga:<id>,li:<id>", example: "fb:123,ga:AW-456" }
        clicks:         { type: integer, format: int64, example: 42 }

    LinkList:
      type: object
      properties:
        ok:        { type: boolean, example: true }
        page:      { type: integer, example: 1 }
        page_size: { type: integer, example: 25 }
        total:     { type: integer, format: int64, example: 137 }
        links:
          type: array
          items:
            $ref: "#/components/schemas/Link"

    LinkWriteRequest:
      type: object
      required: [apiKey, url]
      properties:
        apiKey:         { type: string, description: Your Yak Link API key }
        url:            { type: string, format: uri, description: "Destination URL (http:// or https://)" }
        domain:         { type: string, default: "yak.link" }
        slug:           { type: string, pattern: "^[A-Za-z0-9_-]+$" }
        og_title:       { type: string }
        og_description: { type: string }
        og_image_url:   { type: string, format: uri }
        ios_url:        { type: string, format: uri, description: "iOS visitors are redirected here when set" }
        android_url:    { type: string, format: uri, description: "Android visitors are redirected here when set" }
        expires_at:     { type: integer, format: int64, description: "Unix seconds after which the link 410s (omit / 0 = never)" }
        activates_at:   { type: integer, format: int64, description: "Unix seconds before which the link 404s (omit / 0 = immediately)" }
        password:       { type: string, description: "Plaintext password; stored bcrypt-hashed. Visitors must supply it to be redirected." }
        redirect_type:  { type: integer, enum: [301, 302], default: 302 }
        tags:           { type: string, description: "Comma-separated tags for organising links" }
        folder:         { type: string, description: "Optional folder label" }
        retarget_pixels: { type: string, description: "Retargeting pixels: fb:<id>,ga:<id>,li:<id>" }

    LinkUpdateRequest:
      type: object
      description: >
        At least one editable field must be present. Only supplied fields change.
        `apiKey` authenticates the request and is not itself a link field.
      required: [apiKey]
      properties:
        apiKey:         { type: string, description: Your Yak Link API key }
        url:            { type: string, format: uri }
        slug:           { type: string, pattern: "^[A-Za-z0-9_-]+$" }
        og_title:       { type: string }
        og_description: { type: string }
        og_image_url:   { type: string, format: uri }
        ios_url:        { type: string, format: uri }
        android_url:    { type: string, format: uri }
        expires_at:     { type: integer, format: int64, description: "Unix seconds; 0 clears the expiry" }
        activates_at:   { type: integer, format: int64, description: "Unix seconds; 0 clears the schedule" }
        password:       { type: string, description: "New password; empty string removes protection" }
        redirect_type:  { type: integer, enum: [301, 302] }
        tags:           { type: string, description: "Comma-separated tags" }

    RoutingRule:
      type: object
      properties:
        id:          { type: integer, format: int64, example: 3 }
        kind:        { type: string, enum: [geo, device, ab], example: "geo" }
        match_value: { type: string, example: "US" }
        destination: { type: string, format: uri, example: "https://example.com/us" }
        weight:      { type: integer, example: 1 }

    Webhook:
      type: object
      properties:
        id:          { type: integer, format: int64, example: 7 }
        target_url:  { type: string, format: uri, example: "https://app.example.com/hooks/yaklink" }
        event_types: { type: string, example: "*" }
        active:      { type: boolean, example: true }

    Error:
      type: object
      properties:
        ok:
          type: boolean
          description: Always `false` on error
          example: false
        error:
          type: string
          description: Machine-readable error code
          example: "slug_taken"
        message:
          type: string
          description: Human-readable description of the error
          example: "Slug 'summer-sale' is already taken on yak.link."

  parameters:
    apiKey:
      name: apiKey
      in: query
      required: true
      description: Your Yak Link API key
      schema:
        type: string
        example: "aBcDeFgHiJkLmNoPqRsTuVw"

  responses:

    BadRequest:
      description: |
        Bad request — a required parameter is missing or a value is invalid.
        Common `error` codes: `missing_url`, `invalid_url`, `invalid_slug`.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            missing_url:
              value:
                ok: false
                error: "missing_url"
                message: "url parameter is required."
            invalid_url:
              value:
                ok: false
                error: "invalid_url"
                message: "url must begin with http:// or https://."
            invalid_slug:
              value:
                ok: false
                error: "invalid_slug"
                message: "slug may only contain letters, digits, hyphens, and underscores."

    Unauthorized:
      description: |
        Missing or invalid API key (`error: "missing_api_key"` or `"invalid_api_key"`).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            ok: false
            error: "missing_api_key"
            message: "apiKey parameter is required."

    QuotaExceeded:
      description: |
        The API key has reached its monthly link-creation quota.
        Upgrade your plan or wait until the quota resets at the start of the next month.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            ok: false
            error: "invalid_api_key"
            message: "API key is invalid or has reached its monthly link limit."

    SlugConflict:
      description: |
        The requested slug is already in use on the given domain (`error: "slug_taken"`).
        Choose a different slug or omit it to have one auto-generated.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            ok: false
            error: "slug_taken"
            message: "Slug 'summer-sale' is already taken on yak.link."

    DomainNotFound:
      description: |
        The specified domain is not a default domain and is not registered to
        your account (`error: "domain_not_found"`).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            ok: false
            error: "domain_not_found"
            message: "Custom domain 'my.brand' is not registered to your account."

    NotFound:
      description: |
        No link with that id belongs to the authenticated account (`error: "not_found"`).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            ok: false
            error: "not_found"
            message: "Link not found."

    InternalError:
      description: Unexpected server error
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            ok: false
            error: "db_error"
            message: "Database error: ..."
