openapi: 3.0.3
info:
  title: Easerix Links API
  description: |
    Short links + click analytics (backend/services/products/links).
    Authenticated management API on the service host; the public redirect is
    served per short code (esrx.ly/{code}) by the same service via the edge proxy.
    NOTE — recorded drift: this service responds in camelCase while auth uses
    snake_case (see contracts/README.md).
  version: 1.0.0
  contact:
    name: Perizer Labs
    url: https://easerix.com
servers:
  - url: https://api.easerix.com/links
    description: Production
tags:
  - name: system
    description: Health and liveness
  - name: links
    description: Short-link management
  - name: analytics
    description: Click analytics
  - name: webhooks
    description: Outbound event webhooks
  - name: bio
    description: Link-in-bio pages

security:
  - bearer: []

paths:
  /health:
    get:
      operationId: health
      tags: [system]
      description: Unauthenticated liveness check used by the platform and monitors.
      summary: Liveness probe
      security: []
      responses:
        "200":
          description: Service is up
          content:
            application/json:
              schema:
                type: object

  /v1/links:
    get:
      operationId: listLinks
      tags: [links]
      description: >-
        Links the caller can read, newest first, paginated: their own
        (active-org-scoped; legacy rows without org_id stay visible) plus any
        shared with the organization by a teammate. Archived links are hidden
        unless archived=1. q searches title/destination/code server-side;
        status and tag filter; sort=clicks orders by click count.
      summary: List the links the caller can read
      parameters:
        - name: scope
          in: query
          description: >-
            all (default) — everything readable · mine — only the caller's own
            · shared — only teammates' org-shared links (excludes own).
          schema:
            type: string
            enum: [all, mine, shared]
        - name: q
          in: query
          schema: { type: string }
        - name: sort
          in: query
          schema: { type: string, enum: [clicks] }
        - name: status
          in: query
          schema: { type: string, enum: [active, paused] }
        - name: tag
          in: query
          schema: { type: string }
        - name: archived
          in: query
          description: "\"1\" lists archived links instead of live ones"
          schema: { type: string, enum: ["1"] }
        - name: limit
          in: query
          schema: { type: integer, default: 50, maximum: 100 }
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
      responses:
        "200":
          description: One page of links, newest first
          content:
            application/json:
              schema:
                type: object
                required: [links, total, limit, offset]
                properties:
                  links:
                    type: array
                    items: { $ref: "#/components/schemas/Link" }
                  total: { type: integer }
                  limit: { type: integer }
                  offset: { type: integer }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
    post:
      operationId: createLink
      tags: [links]
      description: Creates a short link. The destination is normalized to https:// when no scheme is given; customCode collisions return 409.
      summary: Create a short link
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [destinationUrl]
              properties:
                destinationUrl: { type: string }
                title: { type: string }
                customCode: { type: string }
                tags:
                  type: array
                  items: { type: string }
                expiresAt:
                  type: string
                  format: date-time
                  nullable: true
      responses:
        "201":
          description: Link created
          content:
            application/json:
              schema:
                type: object
                required: [link]
                properties:
                  link: { $ref: "#/components/schemas/Link" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "409":
          description: Custom code already taken
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/links/batch:
    post:
      operationId: batchLinks
      tags: [links]
      description: >-
        Applies one action to up to 100 of the caller's links. Links the caller
        cannot touch are silently skipped; the response counts what actually
        changed. delete is permanent and removes click history.
      summary: Bulk pause, activate, archive, unarchive, or delete
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids, action]
              properties:
                ids:
                  type: array
                  maxItems: 100
                  items: { type: string }
                action:
                  type: string
                  enum: [pause, activate, archive, unarchive, delete]
      responses:
        "200":
          description: Count of affected links
          content:
            application/json:
              schema:
                type: object
                properties:
                  updated: { type: integer }
                  deleted: { type: integer }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          description: None of the ids resolve to a link the caller owns
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/metadata:
    get:
      operationId: getMetadata
      tags: [links]
      description: >-
        Fetches the destination page (5s timeout, 512KB cap, private addresses
        refused) and returns its og:title or <title> for autofilling the link
        title. Non-HTML or error responses return an empty title rather than
        failing.
      summary: Page title for a destination URL
      parameters:
        - name: url
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Extracted title, possibly empty
          content:
            application/json:
              schema:
                type: object
                required: [title]
                properties:
                  title: { type: string }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "502":
          description: The page could not be fetched
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/links.csv:
    get:
      operationId: exportLinks
      tags: [links]
      description: >-
        The caller's links as a CSV attachment, honoring the same filters as
        the list endpoint (q, status, tag, archived) without pagination.
        Capped at 10000 rows; a trailing "truncated" row signals the cap.
      summary: Export links as CSV
      parameters:
        - name: q
          in: query
          schema: { type: string }
        - name: status
          in: query
          schema: { type: string, enum: [active, paused] }
        - name: tag
          in: query
          schema: { type: string }
        - name: archived
          in: query
          schema: { type: string, enum: ["1"] }
      responses:
        "200":
          description: CSV attachment
          content:
            text/csv:
              schema: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"

  /v1/links/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: getLink
      tags: [links]
      description: >-
        One link with its 20 most recent human clicks, a daily click series, and
        country/device/browser/referrer breakdowns for the requested window.
        Bot traffic is excluded everywhere. Readable by the owner and, when
        visibility is org, by everyone in the organization.
      summary: Link detail with recent clicks, series, and breakdowns
      parameters:
        - $ref: "#/components/parameters/Days"
      responses:
        "200":
          description: Link detail
          content:
            application/json:
              schema:
                type: object
                required: [link, recentClicks, series, breakdowns, conversions, rules, days]
                properties:
                  link: { $ref: "#/components/schemas/Link" }
                  recentClicks:
                    type: array
                    items: { $ref: "#/components/schemas/Click" }
                  series:
                    type: array
                    items: { $ref: "#/components/schemas/DayCount" }
                  breakdowns: { $ref: "#/components/schemas/Breakdowns" }
                  conversions: { $ref: "#/components/schemas/ConversionStats" }
                  rules:
                    type: array
                    items: { $ref: "#/components/schemas/LinkRule" }
                  days: { type: integer }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updateLink
      tags: [links]
      description: >-
        Partial update — only provided fields change. Setting active=false
        disables the public redirect without deleting history; archived=true
        hides the link from the default list and pauses its redirect. shortCode
        changes are validated for format, reservation, and uniqueness (409 on
        conflict). An empty expiresAt clears the expiration. Owner-only: a link
        shared with the caller by a teammate is readable but returns 403 here.
      summary: Update destination, title, code, tags, expiration, visibility, or state
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                destinationUrl: { type: string }
                title: { type: string }
                shortCode: { type: string }
                tags:
                  type: array
                  items: { type: string }
                active: { type: boolean }
                archived: { type: boolean }
                trackConversions: { type: boolean }
                visibility:
                  type: string
                  enum: [private, org]
                  description: >-
                    private = owner only · org = readable by everyone in the
                    caller's organization. Anything else is rejected with 400.
                expiresAt:
                  type: string
                  description: RFC3339; empty string clears the expiration
      responses:
        "200":
          description: Updated link
          content:
            application/json:
              schema:
                type: object
                required: [link]
                properties:
                  link: { $ref: "#/components/schemas/Link" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/MutationForbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Requested short code is reserved or already taken
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    delete:
      operationId: deleteLink
      tags: [links]
      description: >-
        Permanently deletes the link and its click history. Owner-only: a link
        shared with the caller by a teammate is readable but returns 403 here.
      summary: Delete a link
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/MutationForbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/links/{id}/qr:
    get:
      operationId: getLinkQr
      tags: [links]
      description: >-
        PNG QR code encoding the link's short URL. size is clamped to 128–1024
        (default 512); fg/bg are 6-digit hex colors; download=1 switches the
        Content-Disposition to attachment.
      summary: QR code for a short link (PNG)
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: size
          in: query
          schema: { type: integer, default: 512, minimum: 128, maximum: 1024 }
        - name: fg
          in: query
          description: Foreground hex color, e.g. 1a1a1a
          schema: { type: string }
        - name: bg
          in: query
          description: Background hex color, e.g. ffffff
          schema: { type: string }
        - name: download
          in: query
          schema: { type: string, enum: ["1"] }
      responses:
        "200":
          description: PNG image
          content:
            image/png:
              schema: { type: string, format: binary }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/links/{id}/clicks.csv:
    get:
      operationId: exportLinkClicks
      tags: [analytics]
      description: >-
        Raw enriched click log for one link over the requested window as a CSV
        attachment (at, country, device, browser, os, referrerHost, referrer,
        isBot). Bot rows are included and flagged, unlike the in-app analytics
        which exclude them. Capped at 50000 rows; a trailing "truncated" row
        signals the cap.
      summary: Export a link's clicks as CSV
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/Days"
      responses:
        "200":
          description: CSV attachment
          content:
            text/csv:
              schema: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/links/{id}/rules:
    post:
      operationId: setLinkRules
      tags: [links]
      description: >-
        Replaces the link's routing rules. On redirect, geo rules (2-letter
        country) match first, then device rules (mobile/tablet/desktop), then
        split rules pick a destination by weight — the unallocated remainder
        stays on the main destination. Clicks record the winning rule's label
        as their variant.
      summary: Replace a link's routing rules
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [rules]
              properties:
                rules:
                  type: array
                  maxItems: 20
                  items: { $ref: "#/components/schemas/LinkRule" }
      responses:
        "200":
          description: The saved rule set
          content:
            application/json:
              schema:
                type: object
                required: [rules]
                properties:
                  rules:
                    type: array
                    items: { $ref: "#/components/schemas/LinkRule" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/track/conversion:
    post:
      operationId: trackConversion
      tags: [analytics]
      description: >-
        Attributes a conversion to a click. Links with trackConversions enabled
        append ?esrx_id=<click id> to their destination; the customer's backend
        captures it and posts it here (typically with an org API key) when the
        visitor signs up or pays. Clicks are written asynchronously, so an
        instant conversion can 404 once — retry after a second.
      summary: Record a conversion for a click
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [clickId]
              properties:
                clickId: { type: string, description: "The esrx_id captured from the landing URL" }
                name: { type: string, description: "e.g. signup, purchase (default conversion)" }
                amountCents: { type: integer, minimum: 0 }
                currency: { type: string, description: "3-letter code, default USD" }
      responses:
        "201":
          description: Conversion recorded
          content:
            application/json:
              schema:
                type: object
                required: [conversion]
                properties:
                  conversion:
                    type: object
                    properties:
                      id: { type: string }
                      linkId: { type: string }
                      name: { type: string }
                      amountCents: { type: integer }
                      currency: { type: string }
                      created: { type: string, format: date-time }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          description: Unknown clickId, or the click's link is out of scope
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/bio:
    get:
      operationId: listBioPages
      tags: [bio]
      summary: List the caller's bio pages
      responses:
        "200":
          description: Bio pages with their items
          content:
            application/json:
              schema:
                type: object
                required: [pages]
                properties:
                  pages:
                    type: array
                    items: { $ref: "#/components/schemas/BioPage" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
    post:
      operationId: createBioPage
      tags: [bio]
      description: >-
        Claims a handle (3–30 lowercase letters/numbers/-/_, reserved names
        rejected). The public page renders at esrx.ly/@handle once published.
      summary: Create a bio page
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [handle]
              properties:
                handle: { type: string }
                title: { type: string }
                bio: { type: string }
      responses:
        "201":
          description: Created (unpublished)
          content:
            application/json:
              schema:
                type: object
                required: [page]
                properties:
                  page: { $ref: "#/components/schemas/BioPage" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "409":
          description: Handle reserved or already taken
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/bio/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    patch:
      operationId: updateBioPage
      tags: [bio]
      description: Partial update; items, when present, replace the whole set (max 30).
      summary: Update a bio page
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string }
                bio: { type: string }
                published: { type: boolean }
                items:
                  type: array
                  maxItems: 30
                  items:
                    type: object
                    required: [label, url]
                    properties:
                      label: { type: string }
                      url: { type: string }
      responses:
        "200":
          description: Updated page
          content:
            application/json:
              schema:
                type: object
                required: [page]
                properties:
                  page: { $ref: "#/components/schemas/BioPage" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      operationId: deleteBioPage
      tags: [bio]
      summary: Delete a bio page
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/webhooks:
    get:
      operationId: listWebhooks
      tags: [webhooks]
      description: The caller's outbound webhooks. Secrets are never returned here.
      summary: List webhooks
      responses:
        "200":
          description: Webhooks, newest first
          content:
            application/json:
              schema:
                type: object
                required: [webhooks]
                properties:
                  webhooks:
                    type: array
                    items: { $ref: "#/components/schemas/Webhook" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
    post:
      operationId: createWebhook
      tags: [webhooks]
      description: >-
        Registers an https endpoint for link.created/updated/deleted and
        link.clicked events (click events are aggregated per link per flush,
        not one call per click). Deliveries are signed with HMAC-SHA256 in
        X-Easerix-Signature ("sha256=<hex>" over the raw body). The signing
        secret is returned ONCE in this response. Endpoints failing 20
        consecutive deliveries are auto-deactivated.
      summary: Create a webhook
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, description: "https:// endpoint" }
                events:
                  type: array
                  description: Defaults to the three lifecycle events
                  items:
                    type: string
                    enum: [link.created, link.updated, link.deleted, link.clicked]
      responses:
        "201":
          description: Created — includes the secret, shown only this once
          content:
            application/json:
              schema:
                type: object
                required: [webhook]
                properties:
                  webhook: { $ref: "#/components/schemas/Webhook" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"

  /v1/webhooks/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    patch:
      operationId: updateWebhook
      tags: [webhooks]
      description: Update url, events, or active. Re-activating resets the failure count.
      summary: Update a webhook
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                url: { type: string }
                events:
                  type: array
                  items:
                    type: string
                    enum: [link.created, link.updated, link.deleted, link.clicked]
                active: { type: boolean }
      responses:
        "200":
          description: Updated webhook
          content:
            application/json:
              schema:
                type: object
                required: [webhook]
                properties:
                  webhook: { $ref: "#/components/schemas/Webhook" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      operationId: deleteWebhook
      tags: [webhooks]
      summary: Delete a webhook
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/webhooks/{id}/test:
    post:
      operationId: testWebhook
      tags: [webhooks]
      description: Queues a signed "ping" delivery so the endpoint wiring can be verified.
      summary: Send a test delivery
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "202":
          description: Test delivery queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
        "503":
          description: Delivery queue is full
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /{code}:
    get:
      operationId: redirect
      tags: [links]
      summary: Public short-link redirect
      description: >-
        The product itself: 302 to the destination URL (recording the click) when
        the link exists, is active, and is unexpired — otherwise 302 to the web
        app's /unavailable page with a reason (notfound, paused, expired).
        Unauthenticated; served on esrx.ly. Resolves by short code alone — org,
        owner, and visibility never narrow it, so a private link still redirects
        for anyone holding the code.
      security: []
      parameters:
        - name: code
          in: path
          required: true
          schema: { type: string }
      responses:
        "302":
          description: Redirect to the destination, or to /unavailable with a reason
          headers:
            Location:
              schema: { type: string }

  /v1/analytics/summary:
    get:
      operationId: analyticsSummary
      tags: [analytics]
      description: >-
        Account-wide totals, the top five links by clicks, a daily click series,
        and country/device/browser/referrer breakdowns across all links for the
        requested window. Bot traffic is excluded from series and breakdowns.
      summary: Account-wide link analytics
      parameters:
        - $ref: "#/components/parameters/Days"
      responses:
        "200":
          description: Totals, top links, click series, and breakdowns
          content:
            application/json:
              schema:
                type: object
                required: [totalLinks, activeLinks, totalClicks, topLinks, byDay, breakdowns, conversions, days]
                properties:
                  totalLinks: { type: integer }
                  activeLinks: { type: integer }
                  totalClicks: { type: integer }
                  topLinks:
                    type: array
                    maxItems: 5
                    items: { $ref: "#/components/schemas/Link" }
                  byDay:
                    type: array
                    items: { $ref: "#/components/schemas/DayCount" }
                  breakdowns: { $ref: "#/components/schemas/Breakdowns" }
                  conversions: { $ref: "#/components/schemas/ConversionStats" }
                  days: { type: integer }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"

components:
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      bearerFormat: JWT
  parameters:
    Days:
      name: days
      in: query
      description: Analytics window in days; other values fall back to 14
      schema:
        type: integer
        enum: [7, 14, 30, 90]
        default: 14
  schemas:
    Link:
      type: object
      required: [id, shortCode, shortUrl, destinationUrl, clicks, active, created, updated, visibility, ownerId]
      properties:
        id: { type: string }
        shortCode: { type: string }
        shortUrl: { type: string }
        destinationUrl: { type: string }
        title: { type: string }
        tags:
          type: array
          items: { type: string }
        clicks:
          type: integer
          description: Lifetime human (non-bot) clicks
        active: { type: boolean }
        archived: { type: boolean }
        trackConversions:
          type: boolean
          description: When true the redirect appends ?esrx_id=<click id> to the destination
        expiresAt:
          type: string
          format: date-time
          nullable: true
        created: { type: string, format: date-time }
        updated: { type: string, format: date-time }
        visibility:
          type: string
          enum: [private, org]
          description: >-
            private = owner only · org = readable by everyone in the
            organization. Sharing never affects the public redirect.
        ownerId:
          type: string
          description: Owner of the link; compare with the caller to render "shared by a teammate"
    Click:
      type: object
      required: [referrer, referrerHost, userAgent, country, device, browser, os, at]
      properties:
        referrer: { type: string }
        referrerHost:
          type: string
          description: Lowercased referrer hostname without www; empty for direct visits
        userAgent: { type: string }
        country:
          type: string
          description: ISO 3166-1 alpha-2; empty when unknown
        device:
          type: string
          enum: [desktop, mobile, tablet, bot, other]
        browser: { type: string }
        os: { type: string }
        at: { type: string, format: date-time }
    Breakdowns:
      type: object
      description: Top-10 click counts per dimension over the window, bots excluded
      required: [byCountry, byDevice, byBrowser, byReferrer]
      properties:
        byCountry:
          type: array
          items: { $ref: "#/components/schemas/KeyCount" }
        byDevice:
          type: array
          items: { $ref: "#/components/schemas/KeyCount" }
        byBrowser:
          type: array
          items: { $ref: "#/components/schemas/KeyCount" }
        byReferrer:
          type: array
          description: Keyed by referrer host; "direct" groups visits with no referrer
          items: { $ref: "#/components/schemas/KeyCount" }
        byVariant:
          type: array
          description: Rule-label variants; empty unless routing rules chose destinations in the window
          items: { $ref: "#/components/schemas/KeyCount" }
    KeyCount:
      type: object
      required: [key, clicks]
      properties:
        key: { type: string }
        clicks: { type: integer }
    ConversionStats:
      type: object
      required: [count, revenueCents]
      properties:
        count: { type: integer }
        revenueCents: { type: integer }
    LinkRule:
      type: object
      required: [kind, destinationUrl]
      properties:
        id: { type: string, description: "Server-assigned; ignored on input" }
        kind:
          type: string
          enum: [geo, device, split]
        match:
          type: string
          description: 2-letter country for geo; mobile/tablet/desktop for device; unused for split
        destinationUrl: { type: string }
        weight:
          type: integer
          description: Split share 1–99; split weights together must sum to ≤99
        label:
          type: string
          description: Analytics variant label; defaults to kind:match
    BioPage:
      type: object
      required: [id, handle, publicUrl, title, bio, published, views, items, created, updated]
      properties:
        id: { type: string }
        handle: { type: string }
        publicUrl: { type: string }
        title: { type: string }
        bio: { type: string }
        published: { type: boolean }
        views: { type: integer }
        items:
          type: array
          items:
            type: object
            required: [id, label, url]
            properties:
              id: { type: string }
              label: { type: string }
              url: { type: string }
        created: { type: string, format: date-time }
        updated: { type: string, format: date-time }
    Webhook:
      type: object
      required: [id, url, events, active, failureCount, lastStatus, created]
      properties:
        id: { type: string }
        url: { type: string }
        events:
          type: array
          items: { type: string }
        active: { type: boolean }
        failureCount:
          type: integer
          description: Consecutive failed deliveries; reset on success
        lastStatus:
          type: integer
          description: HTTP status of the most recent delivery (0 = network error)
        lastDelivery:
          type: string
          format: date-time
          nullable: true
        secret:
          type: string
          description: Present only in the creation response
        created: { type: string, format: date-time }
    DayCount:
      type: object
      required: [date, clicks]
      properties:
        date:
          type: string
          description: UTC day, YYYY-MM-DD
        clicks: { type: integer }
    Error:
      type: object
      required: [error]
      properties:
        error: { type: string }
  responses:
    BadRequest:
      description: Invalid request body
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: >-
        Missing/invalid bearer token, or a token without org context
        ("token missing org context — refresh your session")
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ToolDisabled:
      description: The links tool is turned off for the caller's organization
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    MutationForbidden:
      description: >-
        Either the links tool is turned off for the caller's organization, or
        the link is org-shared with the caller and they are not its owner
        ("this link is shared with you — only its owner can change it").
        Sharing widens reads, never writes.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Link not found or not owned by the caller
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
