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
  - name: campaigns
    description: Persisted, reusable UTM campaigns

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

  /docs:
    get:
      operationId: apiReference
      tags: [system]
      summary: API reference UI (password-protected)
      description: >-
        Scalar API reference for the running instance, gated by HTTP Basic
        Auth (DOCS_USER/DOCS_PASSWORD) — not linked from any product surface.
        The route is only registered at all when both env vars are set; an
        unconfigured deployment 404s here instead of serving it open.
      security:
        - docsBasic: []
      responses:
        "200":
          description: The reference UI
          content:
            text/html:
              schema:
                type: string

  /docs/openapi.yaml:
    get:
      operationId: openAPISpec
      tags: [system]
      summary: This contract, served by the running instance (password-protected)
      description: >-
        The embedded copy of this OpenAPI document — backs the reference UI
        above and is handy for local codegen against a running instance. Same
        Basic Auth gate as /docs.
      security:
        - docsBasic: []
      responses:
        "200":
          description: The OpenAPI document
          content:
            text/plain:
              schema:
                type: string

  /debug/webhooks/stats:
    get:
      operationId: webhookDispatcherStats
      tags: [system]
      summary: Webhook dispatcher observability snapshot (password-protected)
      description: >-
        Operator-facing, not tenant-facing: aggregate, cross-tenant webhook
        delivery health (pending count, oldest pending age, outcome counters,
        latency, worker utilization, per-destination-host failure rate) for
        this process's dispatcher. Gated by the same Basic Auth as /docs,
        never the per-tenant v1 auth group, since this is cross-tenant
        volume/health data no single authenticated caller's own webhooks
        should reveal.
      security:
        - docsBasic: []
      responses:
        "200":
          description: The dispatcher's current stats snapshot
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookDispatcherStats" }

  /debug/clicks/stats:
    get:
      operationId: clickStats
      tags: [system]
      summary: Click writer and recount counters (password-protected)
      description: >-
        Operator-facing, not tenant-facing: this process's click writer queue
        and drop/failure counters, and the click recount job's drift counters.
        Reset to zero on restart and not aggregated across replicas. Gated by
        the same Basic Auth as /docs.
      security:
        - docsBasic: []
      responses:
        "200":
          description: The current counters
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ClickStats" }

  /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
          description: >-
            Matches title/destination/code, case-insensitive substring. % and
            _ (and the escape char itself) are escaped before reaching the
            LIKE query (EAS-39) — they match themselves literally, never as
            SQL wildcards.
          schema: { type: string }
        - name: sort
          in: query
          schema: { type: string, enum: [clicks] }
        - name: status
          in: query
          description: >-
            flagged (EAS-76/EAS-137) matches a link whose own destination OR
            any of its rules' destinations has a flagged screening verdict.
          schema:
            type: string
            enum: [active, paused, flagged]
        - name: tag
          in: query
          description: >-
            Exact tag match. Same LIKE-wildcard escaping as q (EAS-39) — a
            tag literally containing % or _ is matched correctly.
          schema: { type: string }
        - name: campaignId
          in: query
          description: >-
            Restrict to links created from (or currently pointed at) this
            campaign. Must be a UUID; anything else is a 400. A well-formed id
            that matches no campaign is a 200 with an empty list.
          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 }
                  asOf: { type: string, format: date-time, description: "Server time this response was read at. A live page keeps only the live-feed events whose committedAt is later, since earlier ones are already counted here. Not an exact cutoff: a click can commit in the narrow gap between this value and the database read it describes, landing in both this response and a later live event — expect a rare, self-correcting double-count on the client, not an exact one-to-one split. That holds only when the same server clock stamped both values; across a multi-replica deployment, clock skew between the replica that recorded a click and the one that served this read can instead make a live event look already counted here when it isn't yet, dropping it until the next read." }
        "400":
          description: campaignId is not a valid UUID
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"
    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. When campaignId
        is given, the campaign must belong to the caller's own workspace and
        must not be archived (400 otherwise); its current UTM values are
        copied onto the link as an immutable snapshot and merged onto the
        destination as utm_* query parameters (EAS-73) — existing query
        parameters and the fragment are preserved, and a utm_* parameter
        already present in destinationUrl is replaced, never duplicated.
        Creating a link is audited as link.created (code and destination,
        the destination without its query string or fragment). retargeting
        (EAS-78) opts the link into retargeting pixels — see
        RetargetingInput and the redirect operation; omit it and the link
        keeps the plain 302.
      summary: Create a short link, optionally from a campaign
      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
                  description: >-
                    Must be in the future and valid RFC3339 (400 otherwise).
                campaignId:
                  type: string
                  description: >-
                    Applies that campaign's current UTM values to this link
                    (see description above). Omit for a link with no campaign,
                    exactly as before this field existed.
                clickLimit:
                  type: integer
                  nullable: true
                  description: >-
                    EAS-85: hard cap on total normal (non-QR) redirects before
                    the link stops serving. Omit or 0 for unlimited; must be a
                    positive integer up to 1,000,000,000 otherwise (negative or larger is rejected, 400).
                    Independent of qrScanLimit and of expiresAt. Enforced by an
                    atomic Redis counter on the redirect path, not a
                    synchronous read of this column.
                qrScanLimit:
                  type: integer
                  nullable: true
                  description: >-
                    EAS-85: hard cap on total QR-code scans, counted
                    completely independently of clickLimit. A request only
                    counts as a QR scan if it carries this link's own
                    persistent QR token (see GET /v1/links/{id}/qr) — an
                    ordinary click can never consume this counter. Same
                    omit/0/positive convention as clickLimit.
                retargeting: { $ref: "#/components/schemas/RetargetingInput" }
      responses:
        "201":
          description: Link created
          content:
            application/json:
              schema:
                type: object
                required: [link]
                properties:
                  link: { $ref: "#/components/schemas/Link" }
        "400":
          description: >-
            Invalid body, campaignId doesn't resolve to a non-archived
            campaign in the caller's own workspace, or retargeting is
            invalid (unknown provider, malformed or duplicate pixel id, more
            than 10 pixels, or enabled with no pixels)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "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. Audited the
        same as the single-item endpoints (EAS-40) — one entry per affected
        link, action link.deleted / link.paused / link.activated /
        link.archived / link.unarchived, and link.retargeting_changed for
        disable_retargeting (only links that had retargeting on get one, the
        same entry a single-link edit writes),
        target_type link, target_id the link's id. There is no links-scoped audit route: read these back via
        auth's GET /v1/workspaces/{id}/audit?tool=links (see auth.v1.yaml) —
        actor_id there is who performed the bulk action, created_at when.
        disable_retargeting (EAS-78) is a bulk kill switch for retargeting
        pixels: it turns them off on every selected link and keeps each
        link's pixel list, so one can be switched back on with PATCH.
      summary: Bulk pause, activate, archive, unarchive, disable retargeting, or delete
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids, action]
              properties:
                ids:
                  type: array
                  maxItems: 100
                  description: Link ids (UUIDs). Any malformed id rejects the whole request with a 400.
                  items: { type: string }
                action:
                  type: string
                  enum: [pause, activate, archive, unarchive, disable_retargeting, 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"
        "408":
          $ref: "#/components/responses/RequestTimeout"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "404":
          description: None of the ids resolve to a link the caller owns
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500":
          $ref: "#/components/responses/InternalServerError"

  /v1/links/import:
    post:
      operationId: importLinks
      tags: [links]
      description: >-
        EAS-72: bulk-creates links from normalized rows (up to 1000 per
        request) — the client parses an uploaded CSV into this shape; this
        endpoint never accepts a raw file. Each row runs the exact same
        validation as POST /v1/links and PATCH /v1/links/{id} — destinationUrl
        normalization/format, customCode's 3-40 char pattern plus
        reserved-word and global uniqueness checks, expiresAt must be
        RFC3339 and in the future (a row that isn't is reported in errors), campaignId scoped to the
        caller's own unarchived campaigns (its UTM values are snapshotted and
        applied, same as createLink), clickLimit/qrScanLimit bounds, a
        password gate (4-72 chars, hashed the same way Update's does — never
        echoed back), visibility, and retargeting pixels (same validation as
        createLink) — independently, so a bad row is
        reported in errors and skipped rather than failing the whole import.
      summary: Bulk-create links from CSV-derived rows, with full single-link capability
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [links]
              properties:
                links:
                  type: array
                  maxItems: 1000
                  items:
                    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
                      campaignId:
                        type: string
                        description: >-
                          Applies that campaign's current UTM values to this
                          row's link (see createLink). Omit for a link with no
                          campaign.
                      clickLimit:
                        type: integer
                        nullable: true
                        description: Same convention as createLink's clickLimit.
                      qrScanLimit:
                        type: integer
                        nullable: true
                        description: Same convention as createLink's qrScanLimit.
                      password:
                        type: string
                        description: >-
                          Sets a visitor-facing password gate on this row's
                          link (4-72 chars). Omit or blank for no gate.
                      visibility:
                        type: string
                        enum: [private, org]
                        description: Defaults to private when omitted.
                      retargeting: { $ref: "#/components/schemas/RetargetingInput" }
      responses:
        "200":
          description: Per-row import result
          content:
            application/json:
              schema:
                type: object
                properties:
                  created: { type: integer }
                  skipped: { type: integer }
                  links:
                    type: array
                    items: { $ref: "#/components/schemas/Link" }
                  errors:
                    type: array
                    items:
                      type: object
                      properties:
                        row:
                          type: integer
                          description: 1-based row number within the submitted links array.
                        error: { type: string }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "408":
          $ref: "#/components/responses/RequestTimeout"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  /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"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "502":
          description: The page could not be fetched
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/retargeting/attestation:
    get:
      operationId: getRetargetingAttestation
      tags: [links]
      description: >-
        The customer attestation a user must accept to switch retargeting on
        (EAS-78), exactly as the server is configured with it. Send the returned
        version back as retargeting.attestation.version with accepted: true on
        any createLink, importLinks or updateLink request that turns retargeting
        on. This is the customer's attestation, not visitor consent. While
        configured is false, enabling retargeting is refused.
      summary: Retargeting attestation wording and version
      responses:
        "200":
          description: The current attestation, or configured false
          content:
            application/json:
              schema:
                type: object
                required: [configured]
                properties:
                  configured: { type: boolean }
                  version:
                    type: string
                    description: Present only when configured.
                  text:
                    type: string
                    description: Present only when configured. Show verbatim.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  /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
          description: >-
            Same LIKE-wildcard escaping as the list endpoint's q (EAS-39) — %
            and _ match themselves literally, not as SQL wildcards.
          schema: { type: string }
        - name: status
          in: query
          schema: { type: string, enum: [active, paused] }
        - name: tag
          in: query
          description: Same LIKE-wildcard escaping as the list endpoint's tag (EAS-39).
          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"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /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 }
                  asOf: { type: string, format: date-time, description: "Server time this response was read at. A live page keeps only the live-feed events whose committedAt is later, since earlier ones are already counted here. Not an exact cutoff: a click can commit in the narrow gap between this value and the database read it describes, landing in both this response and a later live event — expect a rare, self-correcting double-count on the client, not an exact one-to-one split. That holds only when the same server clock stamped both values; across a multi-replica deployment, clock skew between the replica that recorded a click and the one that served this read can instead make a live event look already counted here when it isn't yet, dropping it until the next read." }
                  clicksUsed:
                    type: integer
                    description: >-
                      EAS-85: live count read straight from the Redis usage
                      counter (for display, e.g. "12 / 50 clicks" against
                      link.clickLimit) — not persisted anywhere in Postgres.
                      Distinct from link.clicks, which is a separate,
                      permanent lifetime counter that is never reset:
                      clicksUsed tracks consumption against whichever
                      clickLimit is configured right now, and resets to 0
                      whenever a PATCH actually changes clickLimit. 0 if
                      Redis is unavailable or clickLimit was just changed/is
                      unset. Only exposed here (not on List/Create/Update) to
                      avoid a Redis read per row on a paginated list.
                  qrScansUsed:
                    type: integer
                    description: >-
                      EAS-85: live count read straight from the Redis usage
                      counter (for display, e.g. "12 / 50 QR scans" against
                      link.qrScanLimit) — not persisted anywhere in Postgres.
                      Resets to 0 whenever a PATCH actually changes
                      qrScanLimit (see clicksUsed above for the same
                      convention on the click side). 0 if Redis is
                      unavailable or nothing has been scanned yet. Only
                      exposed here (not on List/Create/Update) to avoid a
                      Redis read per row on a paginated list.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalServerError"
    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.
        Changing active or archived is audited, once per actual change, under
        the same names as the bulk endpoint (link.paused / link.activated /
        link.archived / link.unarchived) — see batchLinks for where to read
        the log. Changes to the destination, short code, click/QR scan
        limits, expiry and password are audited the same way, once per field
        that actually changed: link.destination_changed, link.short_code_changed,
        link.limits_changed, link.expiry_changed, link.retargeting_changed,
        link.password_set and link.password_removed. Entries carry the old and new value, except
        that destinations are recorded without their query string or fragment
        (they can carry tokens) and a password or its hash is never recorded.
        campaignId follows the same omit/empty-string convention as expiresAt:
        omitted leaves the campaign unchanged, "" clears it (the link's UTM
        snapshot and destinationUrl are left exactly as they are — clearing
        only detaches the association), and any other value re-points the
        link to that campaign (must belong to the caller's own workspace and
        not be archived, 400 otherwise), copying its current UTM values onto
        the link and re-applying them to destinationUrl (EAS-73). clickLimit
        and qrScanLimit (EAS-85) follow an omit/0 convention instead: omitted
        (or JSON null) leaves the limit unchanged, 0 clears it (unlimited),
        and a positive integer up to 1,000,000,000 sets it (negative or larger is rejected, 400). A change
        takes effect on the very next redirect, and also resets that kind's
        usage counter (getLink's clicksUsed/qrScansUsed) back to 0 — so
        lowering a limit below what's already been used against it starts
        denying immediately against a fresh count, and raising or clearing an
        exhausted limit starts allowing again immediately. Re-sending the
        same value is a no-op and does not reset anything. This never affects
        link.clicks, which is a separate, permanent lifetime counter.
        password follows an
        omit/empty-string convention too: omitted leaves the gate unchanged,
        "" removes it, and any other value (4-72 characters) sets/replaces
        it — write-only, never returned by this or any other endpoint (see
        Link.passwordProtected).
        retargeting (EAS-78) is merged onto the link's stored config under
        the same row lock the rest of the update uses, so concurrent PATCHes
        — one toggling enabled, one editing pixels — each keep the other's
        field. Omitted fields are left as they are; a pixels array replaces
        the whole list (send [] to clear it); enabled: false keeps the list.
        Takes effect on the next redirect on every replica.
      summary: Update destination, title, code, tags, expiration, visibility, campaign, usage limits, password, retargeting, 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. A changed value must be
                    in the future (400 otherwise); re-sending the link's current
                    expiry unchanged is always accepted, even if it has passed.
                campaignId:
                  type: string
                  description: >-
                    Omit to leave unchanged; "" clears the campaign
                    association; any other value re-points the link to that
                    campaign (see description above).
                clickLimit:
                  type: integer
                  nullable: true
                  description: Omit/null leaves unchanged, 0 clears (unlimited), positive sets the cap (see description above).
                qrScanLimit:
                  type: integer
                  nullable: true
                  description: Same convention as clickLimit, independent counter (see description above).
                password:
                  type: string
                  description: >-
                    Omit to leave the gate unchanged, "" to remove it, or a
                    4-72 character value to set/replace it (see description
                    above). Never returned by any endpoint.
                retargeting: { $ref: "#/components/schemas/RetargetingInput" }
      responses:
        "200":
          description: Updated link
          content:
            application/json:
              schema:
                type: object
                required: [link]
                properties:
                  link: { $ref: "#/components/schemas/Link" }
        "400":
          description: >-
            Invalid body, campaignId doesn't resolve to a non-archived
            campaign in the caller's own workspace, password is a
            non-empty value outside 4-72 characters, or retargeting is
            invalid — including a result that would leave the link enabled
            with no pixels
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/MutationForbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Requested short code is reserved or already taken
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500":
          $ref: "#/components/responses/InternalServerError"
    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"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /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); download=1 switches the Content-Disposition
        to attachment. EAS-85: the encoded URL carries this link's
        persistent QR token (esrx.ly/<code>?qr=<token>), generated on first
        request and unchanged after — so re-fetching or re-downloading the
        code (any size/style) always encodes the same token, and an
        already-printed QR code keeps working. That token is what lets the
        redirect distinguish a QR scan from an ordinary click for
        qrScanLimit enforcement (EAS-85); it is never exposed as its own
        field, only embedded in this PNG.


        EAS-81: renders with the link's persisted qrStyle/qrLogoUrl by
        default. pattern/corner/fg/bg override the persisted style for THIS
        response only, without saving anything — the customize panel uses
        this to preview unsaved changes through the same real rendering
        path (see PUT .../qr/style to persist). A custom logo (qrLogoUrl)
        wins if set; otherwise every link renders with Easerix's small
        default logo until qrLogoRemoved is true. Having any logo at all —
        custom or default — automatically renders at High error correction
        instead of the default Medium; never controlled by the caller. If
        no supported adjustment produces a scannable code, this returns 422
        instead of a PNG.
      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: pattern
          in: query
          description: "EAS-81: preview override for the persisted data-module pattern."
          schema: { type: string, enum: [square, dots, rounded, classy, extra-rounded] }
        - name: corner
          in: query
          description: "EAS-81: preview override for the persisted finder/corner style."
          schema: { type: string, enum: [square, rounded, dot, classy, extra-rounded] }
        - name: fg
          in: query
          description: Foreground hex color override, e.g. 1a1a1a
          schema: { type: string }
        - name: bg
          in: query
          description: Background hex color override, e.g. ffffff
          schema: { type: string }
        - name: bg2
          in: query
          description: >-
            EAS-81: preview override turning the background into a diagonal
            gradient from bg to this hex color. Present-but-empty (or
            invalid) forces a solid background for this preview even if a
            gradient is persisted — omit the param entirely to leave the
            persisted bg2 (or its absence) alone.
          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"
        "422":
          description: >-
            Two distinct reasons share this status: EAS-81, no supported
            adjustment rendered a QR code that decodes back to the link's URL
            with the persisted logo applied; or EAS-76, the link's own base
            destination is flagged as unsafe, so no QR code is rendered for
            it at all (flagged_partial — only a routing rule's destination
            flagged — is unaffected and still renders normally).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /v1/links/{id}/qr/style:
    put:
      operationId: updateLinkQrStyle
      tags: [links]
      summary: "EAS-81: persist QR pattern/corner-style/colors"
      description: >-
        Sets the pattern/cornerStyle/fgColor/bgColor GET .../qr renders
        with by default (that endpoint's own pattern/corner/fg/bg query
        params stay non-persistent previews). Owner-only — a link shared
        with the caller by a teammate is readable but returns 403 here.
        Verified against the link's current logo (a custom upload, the
        built-in default, or none) before saving — a style that's valid on
        its own can still fail to render scannably once composited with
        that logo. EAS-207: whether that happens depends on this link's own
        randomly-generated short code/token, not just the style/logo
        chosen, so it is not rejected: the save succeeds and GET .../qr
        falls back to no logo at whichever size doesn't decode, same as it
        already does for pre-existing links. A style that doesn't render
        scannably even without a logo (e.g. too little color contrast) is
        still rejected with 422.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/QRStyle" }
      responses:
        "200":
          description: Saved style
          content:
            application/json:
              schema:
                type: object
                required: [qrStyle]
                properties:
                  qrStyle: { $ref: "#/components/schemas/QRStyle" }
                  logoApplied:
                    type: boolean
                    description: >-
                      false when the link has a logo that can't be composited
                      scannably for this link's QR code at one or more sizes,
                      so GET .../qr renders those sizes without it (EAS-207).
                      Checked against the link's real QR token (minted by the
                      save if the link had none yet). True otherwise,
                      including when the link has no logo.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/MutationForbidden"
        "422":
          description: >-
            This style doesn't render scannably even without a logo (e.g.
            too little color contrast) — rejected before saving.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /v1/links/{id}/qr/logo:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    post:
      operationId: uploadLinkQrLogo
      tags: [links]
      summary: "EAS-81: upload the QR center logo (multipart, \"file\" field, ≤2MB, PNG or SVG)"
      description: >-
        multipart/form-data upload, reusing the same R2/local media layer
        as uploadBioMedia — narrower than that endpoint's allowlist, and
        PNG/SVG only (no JPEG): a QR logo needs to composite correctly,
        including transparent backgrounds, which JPEG can't represent. An
        SVG upload is rasterized server-side to a fixed-size PNG at upload
        time (not stored as SVG) — everything GET .../qr later reads back
        is a plain raster image either way. Validated by actually decoding
        the PNG or parsing the SVG (with a real check that it contains an
        `<svg>` root, not just that it's well-formed XML), not by trusting
        the declared Content-Type. Also dimension/shape-checked (both PNG
        and SVG, off their own decoded/intrinsic width and height): between
        32px and 2000px on the raster side, and — for either format — width
        and height within 25% of each other, since the logo always
        composites into a SQUARE center slot; a banner/wordmark-shaped
        upload is rejected rather than silently squashed to fit. Sets
        qrLogoUrl and clears qrLogoRemoved (so a
        prior opt-out from the built-in default doesn't shadow this new
        upload), which also makes GET .../qr render at High error
        correction and composite the logo, centered and safely sized,
        going forward. Verified against the link's current style before
        anything is uploaded or saved — a logo that's otherwise valid can
        still fail to render scannably composited with that style. EAS-207:
        whether that happens depends on this link's own randomly-generated
        short code/token, not just the logo/style chosen, so it is not
        rejected: the upload succeeds and GET .../qr falls back to no logo
        at whichever size doesn't decode. Only a link whose current style
        doesn't render scannably even without a logo is rejected with 422
        (fix the style first). If a logo was already set, the previous one
        is deleted from storage — replacing a logo never leaves an
        orphaned object behind. Owner-only, same as PUT .../qr/style.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
      responses:
        "200":
          description: Saved logo
          content:
            application/json:
              schema:
                type: object
                required: [logoUrl]
                properties:
                  logoUrl: { type: string }
                  logoApplied:
                    type: boolean
                    description: >-
                      false when this logo can't be composited scannably for
                      the link's QR code at one or more sizes, so GET .../qr
                      renders those sizes without it (EAS-207). Checked
                      against the link's real QR token (minted by the upload
                      if the link had none yet). True otherwise.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/MutationForbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          description: Logo too large (max 2MB)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: >-
            The link's current QR style doesn't render scannably even
            without a logo — change the style first.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"
        "503":
          description: Image storage not configured
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    delete:
      operationId: deleteLinkQrLogo
      tags: [links]
      summary: "EAS-81: remove the QR center logo, opting out of the built-in default too"
      description: >-
        Deletes the stored custom logo object (if any) and clears
        qrLogoUrl, AND sets qrLogoRemoved so GET .../qr stops applying
        Easerix's built-in default logo as well — after this, the code
        renders with no logo at all (and the default Medium error
        correction) until a new logo is uploaded. Idempotent: deleting when
        there's already no logo at all (no custom logo, and a prior removal
        already opted out of the default) still returns 204. Owner-only,
        same as PUT .../qr/style.
      responses:
        "204":
          description: Deleted (or already empty)
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/MutationForbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /v1/links/{id}/qr/logo/reset:
    post:
      operationId: resetLinkQrLogo
      tags: [links]
      summary: "EAS-81: reset the QR logo back to Easerix's built-in default"
      description: >-
        The customize panel's "Reset to default" action for the logo.
        Clears any custom upload (deleting the stored object) AND clears
        qrLogoRemoved — unlike DELETE .../qr/logo, which always leaves the
        code with no logo at all, this returns it to the untouched state
        where GET .../qr applies the built-in default logo again.
        Idempotent: resetting a link that's already on the default returns
        204. Owner-only, same as PUT .../qr/style.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "204":
          description: Reset (or was already on the default)
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/MutationForbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /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"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /v1/links/stream:
    get:
      operationId: streamLinksWorkspace
      tags: [analytics]
      summary: Live click feed across the caller's own links (SSE)
      description: >-
        EAS-83 follow-up: a workspace-scoped Server-Sent Events feed for
        surfaces that aren't pinned to one link (the Dashboard, Links List,
        Campaigns, and Analytics pages) — same underlying ~500ms click-batch
        pipeline as GET /v1/links/{id}/stream, just a second subscription
        mode over it. Emits `data: {"linkId": "...", "clicks": N,
        "campaignId": "...", "committedAt": "..."}` — a per-link count for that flush, not
        enriched per-click detail — as each affected link's batch is durably
        persisted, plus `: ping` heartbeat comments roughly every 25s.
        `campaignId` is omitted when the link has none, so a client can
        attribute a live click to its campaign (the Campaigns list/detail
        pages) without a second lookup. Scoped to exactly the links the
        caller owns in their active org (the same population GET
        /v1/analytics/summary reports on) — a teammate's org-shared link
        never contributes here, matching that endpoint's existing scope.
        Open streams are capped per workspace and per user on each replica;
        past a cap the response is 429 with a plain-language message and the
        client retries on its own. `committedAt` is when the batch was
        recorded, to compare with the `asOf` of a read. The server ends the
        stream when the caller's access token expires, so a client reconnects
        (refreshing its token) and refetches — clicks in the gap are not
        replayed. Every couple of minutes the server also re-checks that the
        caller still has access (their identity has not changed) and ends the
        stream if not.
      responses:
        "200":
          description: An event stream of {linkId, clicks, campaignId} summaries
          content:
            text/event-stream:
              schema: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /v1/links/{id}/stream:
    get:
      operationId: streamLinkClicks
      tags: [analytics]
      summary: Live click feed for one link (SSE)
      description: >-
        EAS-83: a Server-Sent Events stream of new clicks on this link as
        they're recorded. Layered on top of the existing ~500ms click-batch
        pipeline (never a separate capture path): the connection emits
        `data: <Click>` for each click as its batch is durably persisted
        (bot clicks excluded, same as recentClicks), one JSON object per
        click, plus `: ping` heartbeat comments roughly every 25s. One-way
        and stateless — any replica can serve any client. Same readable
        scope as GET /v1/links/{id}: the owner, or anyone in the
        organization when the link's visibility is "org". EAS-83 follow-up:
        each Click also carries `clicksUsed`/`qrScansUsed` — the same live
        Redis usage-cap counts GET /v1/links/{id} returns at the top level
        — so a client can track the Click/QR-scan-limit stats live instead
        of only refreshing them on the next full reload. Both are omitted
        if the usage limiter isn't wired server-side (same best-effort
        degrade as GET's own clicksUsed/qrScansUsed). Open streams are capped
        per link and per user on each replica; past a cap the response is 429
        with a plain-language message and the client retries on its own. The
        server ends the stream when the caller's access token expires, so a
        client reconnects (refreshing its token) and refetches — clicks in the
        gap are not replayed. Every couple of minutes the server also
        re-checks that the caller still has access (their identity has not
        changed, and the link still exists and is readable by them) and ends
        the stream if not.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: An event stream of Click objects
          content:
            text/event-stream:
              schema: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /v1/links/{id}/rules:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    post:
      operationId: setLinkRules
      tags: [links]
      description: >-
        Syncs the link's routing rules to the given set: a rule whose id
        matches an existing one is updated in place (keeping that id and its
        analytics history), a rule with no id (or an id that doesn't belong
        to this link) is created, and any existing rule left out of the set
        is deleted. For editing or removing a single rule without resending
        the whole list, prefer PATCH/DELETE on /v1/links/{id}/rules/{ruleId}
        instead. An actual change to the rule set is audited as
        link.rules_updated (counts of rules added, removed and changed —
        never their destinations); re-sending an unchanged set is not an
        event, and neither are the single-rule endpoints below when nothing
        changes. On redirect, inactive rules (active: false) are skipped
        entirely regardless of kind; among the rest, geo rules (2-letter
        country) match first, then device rules (mobile/tablet/desktop),
        then schedule rules (active start/end window), 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: Sync a link's routing rules
      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"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /v1/links/{id}/rules/{ruleId}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
      - name: ruleId
        in: path
        required: true
        schema: { type: string }
    patch:
      operationId: updateLinkRule
      tags: [links]
      description: >-
        Updates one routing rule in place. Unlike PATCH /v1/links/{id}/rules
        (a whole-set sync), this never touches any other rule — except that
        the split-weight-sum invariant spans the whole rule set, so an
        update that changes a split rule's weight (or changes a rule's kind
        to/from split) is still checked against every other split rule on
        the link before being applied.
      summary: Update one routing rule
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                kind:
                  type: string
                  enum: [geo, device, schedule, split]
                match: { type: string }
                destinationUrl: { type: string }
                weight: { type: integer }
                label: { type: string }
                startAt: { type: string, format: date-time, description: Schedule rules only }
                endAt: { type: string, format: date-time, description: Schedule rules only }
                active: { type: boolean, description: "Manual on/off switch — see LinkRule.active" }
      responses:
        "200":
          description: The updated rule
          content:
            application/json:
              schema:
                type: object
                required: [rule]
                properties:
                  rule: { $ref: "#/components/schemas/LinkRule" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalServerError"
    delete:
      operationId: deleteLinkRule
      tags: [links]
      description: >-
        Removes one routing rule. Deleting a rule can only ever decrease the
        split-weight sum, so unlike create/update there's no invariant to
        recheck here.
      summary: Delete one routing rule
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /v1/links/{id}/excluded-ips:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    post:
      operationId: setExcludedIps
      tags: [links]
      description: >-
        EAS-82: replaces the link's whole IP exclusion list (whole-set
        sync, same convention as setLinkRules) — lets a team mark their own
        preview/test IPs or CIDR ranges so that traffic stops inflating
        reported reach. A visitor whose client IP matches any entry has
        their click skipped entirely at record time: never written to
        clicks history, never counted toward the link's click total, never
        delivered as a click webhook or live click-stream event, and never
        attributed a conversion — as if the request never happened, not
        merely filtered out of reports afterward. Every other gate a
        redirect goes through (liveness, usage caps, password, screening,
        retargeting consent) is unaffected; only what gets analyzed
        afterward changes. An actual change to the list is audited as
        link.excluded_ips_updated; re-sending an unchanged list is not an
        event. Each entry's range must be at least /24 for IPv4 or /64 for
        IPv6 (400 otherwise) — this excludes a team's own IPs, not a link's
        whole audience, so an accidental near-catch-all like /0 or /4 is
        rejected rather than silently zeroing out a link's analytics.
      summary: Sync a link's analytics IP exclusion list
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [entries]
              properties:
                entries:
                  type: array
                  maxItems: 50
                  items: { $ref: "#/components/schemas/ExcludedIp" }
      responses:
        "200":
          description: The saved exclusion list
          content:
            application/json:
              schema:
                type: object
                required: [excludedIps]
                properties:
                  excludedIps:
                    type: array
                    items: { $ref: "#/components/schemas/ExcludedIp" }
                  warnings:
                    type: array
                    items: { type: string }
                    description: >-
                      Non-blocking advisories about the saved list — e.g. a
                      bare IPv6 address normalizes to a /128, which can stop
                      matching the same visitor within hours under
                      privacy-extension address rotation. Absent when there's
                      nothing to flag.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /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.
        Idempotent on clickId+name: retrying the same clickId with the same
        name (a dropped response, an at-least-once webhook relay) returns 200
        with the already-recorded conversion instead of creating a second one.
        A different name for the same clickId (e.g. "signup", then later
        "purchase") is a distinct event and is always accepted as a new 201.
      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 }
        "200":
          description: >-
            Already recorded — this clickId+name was reported before (a
            retry), so the existing conversion is returned rather than a new
            one being created.
          content:
            application/json:
              schema:
                type: object
                required: [conversion, duplicate]
                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 }
                  duplicate: { type: boolean }
        "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" }
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /v1/bio:
    get:
      operationId: listBioPages
      tags: [bio]
      description: >-
        The caller's bio pages with their items, newest first, paginated the
        same way GET /v1/campaigns is.
      summary: List the caller's bio pages
      parameters:
        - 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 bio pages with their items
          content:
            application/json:
              schema:
                type: object
                required: [pages, total, limit, offset]
                properties:
                  pages:
                    type: array
                    items: { $ref: "#/components/schemas/BioPage" }
                  total: { type: integer }
                  limit: { type: integer }
                  offset: { type: integer }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    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"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "409":
          description: Handle reserved or already taken
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500":
          $ref: "#/components/responses/InternalServerError"

  /v1/bio/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    patch:
      operationId: updateBioPage
      tags: [bio]
      description: >-
        Partial update. When items is present, it's synced against the
        existing set (max 30): an item whose id matches an existing one is
        updated in place (keeping that id), an item with no id (or one that
        doesn't match) is created, and any existing item left out is
        deleted. For adding, editing, or removing a single item without
        resending the whole list, prefer POST/PATCH/DELETE on
        /v1/bio/{id}/items instead.
      summary: Update a bio page
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string }
                bio: { type: string }
                theme: { $ref: "#/components/schemas/BioTheme" }
                published: { type: boolean }
                items:
                  type: array
                  maxItems: 30
                  items:
                    type: object
                    required: [label, url]
                    properties:
                      id:
                        type: string
                        description: >-
                          Matches an existing item to update it in place;
                          omit, or send one that doesn't match, to create a
                          new item.
                      label: { type: string }
                      url: { type: string }
                      icon: { type: integer, minimum: 0 }
                      featured: { type: boolean }
      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"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalServerError"
    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"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /v1/bio/{id}/media/{kind}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
      - name: kind
        in: path
        required: true
        schema: { type: string, enum: [avatar, background] }
    post:
      operationId: uploadBioMedia
      tags: [bio]
      summary: Upload the page's avatar or background image (multipart, "file" field, ≤5MB)
      description: >-
        multipart/form-data upload to R2 (image/png, image/jpeg, image/webp,
        or image/gif). Sets theme.avatarUrl or theme.bgImageUrl depending on
        kind. If that field was already set, the previously-uploaded image is
        deleted from storage — replacing an image never leaves an orphaned
        object behind. 400 when kind is invalid, the file is missing, or the
        content type isn't one of the allowed image types; 413 when the file
        is larger than 5MB; 503 when image storage isn't configured for this
        environment.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
      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"
        "413":
          description: File too large (max 5MB)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "503":
          description: Image storage not configured
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    delete:
      operationId: deleteBioMedia
      tags: [bio]
      summary: Remove the page's avatar or background image
      description: >-
        Deletes the stored object (if any) and clears the corresponding
        theme field. Idempotent — deleting an already-empty kind still
        returns 204.
      responses:
        "204":
          description: Deleted (or already empty)
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "503":
          description: Image storage not configured
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /media/{key}:
    parameters:
      - name: key
        in: path
        required: true
        schema: { type: string }
        description: >-
          Opaque storage key returned (as the path segment after /media/) by
          any upload endpoint that stores files through this shared media
          layer — currently uploadBioMedia's theme.avatarUrl/bgImageUrl.
    get:
      operationId: getMedia
      tags: [bio]
      security: []
      summary: Fetch a stored file (public, unauthenticated)
      description: >-
        Generic public file-serving route shared by every feature that
        stores user-uploaded files through this service's media layer —
        bio-page avatar/background images today. Published bio pages are
        public, so the images they embed must be fetchable without a token;
        this proxies the stored object rather than exposing a raw bucket
        URL, matching the canvas asset-serving pattern.
      responses:
        "200":
          description: Image bytes
          content:
            image/*:
              schema: { type: string, format: binary }
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/public/links/{code}/unlock:
    parameters:
      - name: code
        in: path
        required: true
        schema: { type: string }
    post:
      operationId: unlockLink
      tags: [links]
      security: []
      summary: Submit a password for a password-protected link (public, unauthenticated)
      description: >-
        Checks a visitor-supplied password against a password-protected
        link's stored hash. On success, returns a short-lived, link-scoped
        signed token to append as ?lt=<token> on the next GET /{code} to
        complete the redirect (see that route's description). The token is
        never a cookie or a session — it carries no server-side state and is
        reusable until it expires. Same liveness checks as GET /{code}
        (active, not archived, not expired) apply before the password is
        even compared; a link with no password configured, or one that
        fails those checks, responds 404 identically to an unknown code.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [password]
              properties:
                password: { type: string }
      responses:
        "200":
          description: Correct password — a token to complete the redirect
          content:
            application/json:
              schema:
                type: object
                required: [token]
                properties:
                  token:
                    type: string
                    description: Opaque, short-lived — append as ?lt=<token> to GET /{code}.
        "400":
          description: Invalid body
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: Incorrect password
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: Unknown code, or the link isn't password-protected/live
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /v1/public/links/{code}/status:
    parameters:
      - name: code
        in: path
        required: true
        schema: { type: string }
      - name: reason
        in: query
        required: false
        schema:
          type: string
          enum: [click_limit, qr_limit, flagged]
        description: >-
          The reason the caller's own /unavailable page is currently showing
          (its own reason query param, passed straight through) — omit for a
          /locked-style check, which has no reason of its own. Only that ONE
          gate is re-checked, deliberately NOT every gate this endpoint
          knows about: reusing clickLimit's peek for a qr_limit recheck (or
          vice versa) would report a visitor still blocked because of an
          unrelated cap they were never actually subject to. paused/expired
          are always re-checked regardless of this param (they're universal
          prerequisites that block everything), and so is the password gate
          (checked last, so a link that's since ALSO been password-protected
          is correctly reported "locked" rather than a stale available=true).
    get:
      operationId: linkStatus
      tags: [links]
      security: []
      summary: Re-check whether a link is available again (public, unauthenticated)
      description: >-
        The ONE re-check endpoint both public interstitials use to notice
        something's changed since a visitor landed there: /locked (password
        required) polls this with no reason to see if the gate password was
        removed; /unavailable (paused/expired/click+QR limit (EAS-85)/
        flagged) polls it with its own reason. Peek-only for usage caps:
        never consumes a click or QR-scan slot just because a visitor's
        browser polled this on page load. notfound is never recheckable (a
        code either resolves or it doesn't) and always reports
        available=false.
      responses:
        "200":
          description: Current availability, and why not if unavailable
          content:
            application/json:
              schema:
                type: object
                required: [available]
                properties:
                  available: { type: boolean }
                  reason:
                    type: string
                    enum: [notfound, paused, expired, click_limit, qr_limit, flagged, locked]
                    description: Present only when available is false.
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /v1/public/my-ip:
    get:
      operationId: detectMyIp
      tags: [links]
      security: []
      summary: Detect the caller's own IP for self-exclusion (public, unauthenticated)
      description: >-
        EAS-82/EAS-422: the caller's own IP exactly as a real redirect would
        resolve it (same header priority as GET /{code}), for prefilling a
        link's self-exclusion entry instead of asking a customer to find and
        type their own address (and risk, as EAS-82 did, getting the address
        family wrong). Public and called directly on the short domain rather
        than the authenticated API domain, so the address it reports is the
        one a real click on this link actually resolves to — the same
        resolution, not a second one that could disagree with it. Always
        answers at this bare path, never under any BasePath the deployment
        otherwise serves its management API under. Not scoped to any link or
        workspace: it reflects request metadata, not a stored resource.
        Unlike the other public routes here, the response body is the
        caller's own address, so — unlike them — it is not CORS-open to any
        origin: only the first-party dashboard can read the response
        cross-origin. It also uses its own rate-limit bucket, separate from
        the redirect route's: not because sharing it would open an attack
        that doesn't already exist (a page can already spend a visitor's
        real-redirect budget too, CORS or no CORS), but this route has no DB
        lookup, so it's a cheaper lever on that same budget than a real
        redirect is. Returns null fields rather than an error when the
        address can't be determined, since this only prefills a field the
        caller could already type anything into.
      responses:
        "200":
          description: >-
            The resolved IP, plus an exact host CIDR and a wider range CIDR
            (at setExcludedIps's own minimum width — /24 for IPv4, /64 for
            IPv6), since a bare IPv6 address's host route can stop matching
            the same visitor within hours under privacy-extension rotation.
            All three are null when the address could not be determined.
          headers:
            Cache-Control: { schema: { type: string }, description: "no-store — the response reflects the caller, not a shareable resource." }
          content:
            application/json:
              schema:
                type: object
                required: [ip, exact, widened]
                properties:
                  ip: { type: string, nullable: true }
                  exact: { type: string, nullable: true }
                  widened: { type: string, nullable: true }
        "429":
          $ref: "#/components/responses/TooManyRequests"

  /v1/bio/{id}/preview:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: previewBioPage
      tags: [bio]
      description: >-
        Renders the same HTML as the public esrx.ly/@handle page, scoped to
        the caller's own page and independent of its published state — so a
        draft's theme/layout can be checked before it ever goes live.
        Unlike the public route, this never increments the page's view
        counter.
      summary: Preview a bio page (owner-only, works while unpublished)
      responses:
        "200":
          description: Rendered HTML
          content:
            text/html:
              schema: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/bio/{id}/items:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    post:
      operationId: createBioItem
      tags: [bio]
      description: >-
        Creates one item, appended after the page's current last item (max
        30 per page). Unlike PATCH /v1/bio/{id}'s items field, this never
        touches any other item on the page.
      summary: Add an item to a bio page
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [label, url]
              properties:
                label: { type: string }
                url: { type: string }
      responses:
        "201":
          description: The created item
          content:
            application/json:
              schema:
                type: object
                required: [item]
                properties:
                  item: { $ref: "#/components/schemas/BioItemDetail" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/bio/{id}/items/{itemId}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
      - name: itemId
        in: path
        required: true
        schema: { type: string }
    patch:
      operationId: updateBioItem
      tags: [bio]
      description: >-
        Updates one item in place. label/url are simple field replaces;
        position, when present, moves the item among its siblings (0-based)
        — the rest of the page's items shift to keep positions dense.
      summary: Update one bio page item
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                label: { type: string }
                url: { type: string }
                position: { type: integer, minimum: 0 }
                icon: { type: integer, minimum: 0, description: Index into the shared icon set. }
                featured:
                  type: boolean
                  description: >-
                    At most one featured item per page — setting this true
                    unsets it on every other item.
      responses:
        "200":
          description: The updated item
          content:
            application/json:
              schema:
                type: object
                required: [item]
                properties:
                  item: { $ref: "#/components/schemas/BioItemDetail" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalServerError"
    delete:
      operationId: deleteBioItem
      tags: [bio]
      description: >-
        Removes one item and compacts the remaining items' positions back
        to a dense 0-based sequence.
      summary: Delete one bio page item
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /v1/webhooks:
    get:
      operationId: listWebhooks
      tags: [webhooks]
      description: >-
        The caller's outbound webhooks, newest first, paginated (default 10,
        capped at 100). Secrets are never returned here.
      summary: List webhooks
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 10, maximum: 100 }
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
      responses:
        "200":
          description: One page of webhooks, newest first
          content:
            application/json:
              schema:
                type: object
                required: [webhooks, total, limit, offset]
                properties:
                  webhooks:
                    type: array
                    items: { $ref: "#/components/schemas/Webhook" }
                  total: { type: integer }
                  limit: { type: integer }
                  offset: { type: integer }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    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. Rejected with 400 if it resolves to a
                    private, loopback, link-local, or unspecified address, or
                    the CGNAT (100.64.0.0/10) / 0.0.0.0/8 ranges (EAS-28) —
                    checked again at every delivery dial, not just here, so a
                    hostname that changes DNS after saving is still refused.
                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":
          description: >-
            Invalid body, or URL rejected — must be a publicly reachable
            https:// endpoint (private IPs, localhost, and file:// are
            blocked). The error field says which rule failed: not a valid
            URL, not https://, no host, or a host that resolves to a
            private, loopback, or link-local address (EAS-28).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  /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
                  description: >-
                    Same https:// + not-private/internal/loopback validation
                    as createWebhook (EAS-28); rejected with 400 otherwise.
                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":
          description: >-
            Invalid body, or URL rejected — must be a publicly reachable
            https:// endpoint (private IPs, localhost, and file:// are
            blocked). The error field says which rule failed: not a valid
            URL, not https://, no host, or a host that resolves to a
            private, loopback, or link-local address (EAS-28).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalServerError"
    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"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /v1/webhooks/{id}/regenerate-secret:
    post:
      operationId: regenerateWebhookSecret
      tags: [webhooks]
      description: >-
        Issues a fresh signing secret for the webhook, shown once (same as
        creation). Hard cutover: the old secret stops verifying immediately —
        any delivery enqueued after this call signs with the new secret.
        url, events, active, and delivery history/counters are unaffected,
        unlike delete-and-recreate.
      summary: Regenerate a webhook's signing secret
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Webhook with the new secret, shown once
          content:
            application/json:
              schema:
                type: object
                required: [webhook]
                properties:
                  webhook: { $ref: "#/components/schemas/Webhook" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /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"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalServerError"
        "503":
          description: Delivery queue is full
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/webhooks/{id}/deliveries:
    get:
      operationId: listWebhookDeliveries
      tags: [webhooks]
      description: >-
        This webhook's own delivery attempts, newest first. The payload sent
        and the signature used are never returned here — the receiving
        endpoint already saw both at delivery time; this is for finding
        (and, for a dead one, replaying) a delivery, not re-reading its body.
      summary: List a webhook's deliveries
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: status
          in: query
          description: Filter to one delivery status (e.g. dead).
          schema: { type: string, enum: [pending, delivering, succeeded, dead] }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        "200":
          description: Deliveries for this webhook
          content:
            application/json:
              schema:
                type: object
                required: [deliveries]
                properties:
                  deliveries:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookDelivery" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /v1/webhooks/{id}/deliveries/{deliveryId}/replay:
    post:
      operationId: replayWebhookDelivery
      tags: [webhooks]
      description: >-
        Requeues a dead delivery for another attempt, reusing the same
        delivery record and its existing id — never a new one — so the
        X-Easerix-Delivery header the receiving endpoint already saw stays
        identical across the replay. Only a delivery currently in the dead
        state can be replayed; every other status is rejected. Delivery
        itself happens the normal way afterward (the usual claim/lease/
        retry machinery), not synchronously from this call.
      summary: Replay a dead delivery
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: deliveryId
          in: path
          required: true
          schema: { type: string }
      responses:
        "202":
          description: Delivery requeued for replay
          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"
        "409":
          description: Delivery does not exist, or is not currently dead
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /v1/public/consent:
    post:
      operationId: submitRetargetingConsent
      tags: [links]
      security: []
      summary: Record a visitor's retargeting consent decision (public, unauthenticated)
      description: >-
        Submitted by the consent page's Allow and Decline buttons. The form
        carries only the signed continuation token the page was rendered with
        (t) and the decision (d). It never accepts a destination or URL: the
        303 goes to the short link named inside the token, with its original
        lt/qr query restored, plus a signed, two-minute cr parameter that
        carries the first request's referrer (the 303 sends Referrer-Policy:
        no-referrer, so the return GET never carries the consent page's own URL
        as its Referer). Sets the signed, host-only __Host-esrx_consent
        cookie (Secure, HttpOnly, SameSite=Lax, Path=/, no Domain) holding the
        decision for the configured scope. Same-origin browser submissions
        only: Sec-Fetch-Site, when sent, must be same-origin, and Origin, when
        sent, must be a configured trusted origin (in production only the
        origins listed in CONSENT_TRUSTED_ORIGINS). 404 when the consent flow
        is not configured. Errors are the usual {error} JSON; a browser form
        post (Accept: text/html) gets a small page in the consent layout with
        a way back to the short link instead.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [t, d]
              properties:
                t: { type: string, description: "Signed, short-lived continuation token from the consent page." }
                d: { type: string, enum: [accepted, denied] }
      responses:
        "303":
          description: Decision recorded; go back to the short link
          headers:
            Location: { schema: { type: string }, description: "/{code}, with any lt/qr the original request carried and the signed cr return token." }
            Referrer-Policy: { schema: { type: string }, description: "no-referrer." }
            Set-Cookie: { schema: { type: string }, description: The signed consent cookie. }
        "400":
          description: Invalid, expired or wrong-purpose token, or an invalid decision
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
            text/html: { schema: { type: string } }
        "403":
          description: Cross-site or untrusted-origin request
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
            text/html: { schema: { type: string } }
        "404":
          description: The consent flow is not configured
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
            text/html: { schema: { type: string } }
        "415":
          description: Not application/x-www-form-urlencoded
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
            text/html: { schema: { type: string } }
        "429":
          $ref: "#/components/responses/TooManyRequests"

  /v1/public/consent/choices:
    get:
      operationId: getRetargetingConsentChoices
      tags: [links]
      security: []
      summary: Page for withdrawing retargeting consent given on this browser (public)
      description: >-
        A minimal first-party HTML page (no scripts, never framed) with one
        button that posts to /v1/public/consent/withdraw. ?done=1 shows the
        confirmation instead. Withdrawal only turns consent given on this
        browser off; a visitor who declined is not asked again, and cannot opt
        in from here, until the declined choice expires. 404 when the consent
        flow is not configured.
      responses:
        "200":
          description: The page
          content:
            text/html: { schema: { type: string } }
        "404":
          description: The consent flow is not configured
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
            text/html: { schema: { type: string } }
        "429":
          $ref: "#/components/responses/TooManyRequests"

  /v1/public/consent/assets/plus-jakarta-sans.woff2:
    get:
      operationId: getConsentFont
      tags: [links]
      security: []
      summary: The Easerix typeface used by the consent pages (public)
      description: >-
        The latin subset of Plus Jakarta Sans, embedded in the service and
        served from the short-link origin so the consent pages match the
        dashboard without any third-party font request. Static and cacheable.
        The consent pages' CSP allows fonts from 'self' for this alone.
      responses:
        "200":
          description: The WOFF2 font file
          content:
            font/woff2:
              schema: { type: string, format: binary }

  /v1/public/consent/withdraw:
    post:
      operationId: withdrawRetargetingConsent
      tags: [links]
      security: []
      summary: Withdraw retargeting consent given on this browser (public, unauthenticated)
      description: >-
        Flips every accepted decision in the visitor's consent cookie to
        denied. Same security model as /v1/public/consent: same-origin form
        post with a signed, short-lived, purpose-bound token (t, from the
        choices page). It changes only Easerix's own consent state; it cannot
        delete cookies or storage that an ad provider already wrote.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [t]
              properties:
                t: { type: string }
      responses:
        "303":
          description: Withdrawn; back to the choices page with ?done=1
          headers:
            Location: { schema: { type: string } }
            Set-Cookie: { schema: { type: string }, description: The updated signed consent cookie. }
        "400":
          description: Invalid, expired or wrong-purpose token
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
            text/html: { schema: { type: string } }
        "403":
          description: Cross-site or untrusted-origin request
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
            text/html: { schema: { type: string } }
        "404":
          description: The consent flow is not configured
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
            text/html: { schema: { type: string } }
        "415":
          description: Not application/x-www-form-urlencoded
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
            text/html: { schema: { type: string } }
        "429":
          $ref: "#/components/responses/TooManyRequests"

  /{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. This is the only endpoint that increments
        a link's `clicks` (EAS-31) — see that field's description on the Link
        schema for the atomic-update and async-batching detail. When code
        starts with `@`, this same route renders the public bio page (EAS-37)
        instead of redirecting — there is no separate /v1/bio/{slug} route —
        returning 200 HTML and incrementing that page's `views` (see BioPage.views).
        When the link has a password set (Link.passwordProtected), a request
        without a valid lt query parameter 302s to the web app's /locked page
        instead of the destination or /unavailable — see POST
        /v1/public/links/{code}/unlock for how a visitor obtains that token
        after submitting the correct password. The check runs before routing
        rules, screening, and usage-cap consumption, so none of those are
        ever revealed or spent by a request that hasn't passed it.
        Retargeting pixels (EAS-78): a link that opted in (Link.retargeting)
        keeps this exact flow — rate limit, password, usage caps, screening,
        rule resolution and click recording all run first — and only then,
        for a visitor we may fire for, answers 200 with a small HTML
        interstitial instead of the 302. The page carries the already
        resolved and screened destination, loads the configured providers in
        parallel, and forwards after at most 500 ms whatever the providers do
        (a blocked, slow or failing provider never holds the redirect up; with
        JavaScript disabled it forwards immediately and fires nothing). The
        address bar is scrubbed of query data before any provider code runs,
        and the destination and page script are removed from the DOM, so
        lt/qr tokens and esrx_id are never visible to a pixel. Every other
        request — links without pixels, known bots, link-preview fetchers
        (WhatsApp, Skype and similar, or any fetch whose Sec-Fetch-Mode is not
        navigate), prefetches, visitors sending Sec-GPC or DNT (as
        configured), and visitors whose signed consent decision is denied —
        gets the ordinary 302, so a preview can still read the destination's
        own metadata. Every response for a link with retargeting enabled,
        redirects included, carries Cache-Control: no-store, because it depends
        on cookies, region and the visitor's signals. A visitor in a
        region that requires consent, or of unknown region, with no valid
        decision is instead shown a first-party consent page (200, no
        provider scripts, never framed): this happens after every gate above
        but BEFORE usage-cap consumption and click recording, so the
        decision round trip counts the visitor once. Allow/Decline post to
        POST /v1/public/consent, which sets a signed host-only cookie and
        303s back here, where the decision is read. If the consent flow is not
        configured on the deployment, or the request arrived on a host a
        decision cannot be posted from (anything outside
        CONSENT_TRUSTED_ORIGINS), such visitors get the 302 instead of a prompt
        they could not complete. A visitor returning from a decision with a
        valid cr token but no cookie (cookies blocked) gets the 302 too, never
        a second prompt. Conversion
        tracking is unrelated: pixels never receive esrx_id or any conversion
        data.
      security: []
      parameters:
        - name: code
          in: path
          required: true
          schema: { type: string }
          description: >-
            A link's short code, or @<bio page handle> to render that bio
            page instead of redirecting.
        - name: lt
          in: query
          required: false
          schema: { type: string }
          description: >-
            Short-lived signed token from POST /v1/public/links/{code}/unlock,
            required only when the link is password-protected.
        - name: cr
          in: query
          required: false
          schema: { type: string }
          description: >-
            Signed, two-minute return token added by POST /v1/public/consent's
            303. It names this short code and the original referrer, which the
            click keeps in place of the consent page's own URL. Ignored when
            forged, expired or minted for another link.
      responses:
        "200":
          description: >-
            The bio page's rendered HTML, when code is @<handle> and that
            page is published — or, for a link with retargeting enabled, the
            pixel interstitial described above (see its CSP and
            Cache-Control headers).
          headers:
            Content-Security-Policy:
              description: >-
                Present on the pixel interstitial only. Built per response:
                default-src 'none', a fresh script/style nonce, and only the
                script and beacon origins of the providers configured on this
                link.
              schema: { type: string }
            Cache-Control:
              description: no-store on the pixel interstitial.
              schema: { type: string }
          content:
            text/html:
              schema: { type: string }
        "302":
          description: Redirect to the destination, or to /unavailable with a reason
          headers:
            Location:
              schema: { type: string }
            Referrer-Policy:
              description: >-
                Always no-referrer (EAS-199), on every response from this route.
                A browser therefore never forwards this URL as a Referer to the
                destination, so the password-unlock token (the lt query
                parameter), esrx_id and the short code cannot leak to the
                destination's logs, including for same-origin destinations and
                for browsers or webviews with a looser default policy.
              schema: { type: string, enum: [no-referrer] }
        "429":
          $ref: "#/components/responses/TooManyRequests"

  /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, linksWithClicks, topLinks, byDay, breakdowns, conversions, conversionsByDay, days]
                properties:
                  totalLinks: { type: integer }
                  activeLinks: { type: integer }
                  totalClicks: { type: integer }
                  linksWithClicks:
                    type: integer
                    description: >-
                      Count of links (any status — active, paused, or
                      archived) with at least one lifetime click, i.e. links
                      that actually contribute to totalClicks. The correct
                      denominator for a genuine "average clicks per link that
                      got clicks" figure — dividing totalClicks by totalLinks
                      instead dilutes the average with every link that's
                      never been clicked at all (including ones created
                      moments ago). Distinct from activeLinks above, which
                      means "not paused, not archived" and has nothing to do
                      with click history.
                  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" }
                  conversionsByDay:
                    type: array
                    description: >-
                      Day-bucketed conversion count and revenue, letting the
                      caller compute a trailing period-over-period delta the
                      same way it already can for `byDay` clicks.
                    items: { $ref: "#/components/schemas/ConversionDayCount" }
                  days: { type: integer }
                  asOf: { type: string, format: date-time, description: "Server time this response was read at. A live page keeps only the live-feed events whose committedAt is later, since earlier ones are already counted here. Not an exact cutoff: a click can commit in the narrow gap between this value and the database read it describes, landing in both this response and a later live event — expect a rare, self-correcting double-count on the client, not an exact one-to-one split. That holds only when the same server clock stamped both values; across a multi-replica deployment, clock skew between the replica that recorded a click and the one that served this read can instead make a live event look already counted here when it isn't yet, dropping it until the next read." }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /v1/analytics/campaigns:
    get:
      operationId: campaignAnalytics
      tags: [analytics]
      description: >-
        Campaign-level rollup (EAS-73): total clicks in the window across
        every link belonging to each of the caller's campaigns, aggregated
        strictly through the persisted links.campaign_id relationship — never
        by parsing destinationUrl or UTM query parameters — so "how did Q3
        Email Campaign perform" is one row instead of manually summing every
        link. Archived campaigns are included: they stay reportable for the
        links/clicks already associated with them. A link with no campaign
        never contributes to any row.
      summary: Campaign-level click rollup
      parameters:
        - $ref: "#/components/parameters/Days"
      responses:
        "200":
          description: One row per campaign, most-clicked first
          content:
            application/json:
              schema:
                type: object
                required: [campaigns, days]
                properties:
                  campaigns:
                    type: array
                    items: { $ref: "#/components/schemas/CampaignRollup" }
                  days: { type: integer }
                  asOf: { type: string, format: date-time, description: "Server time this response was read at. A live page keeps only the live-feed events whose committedAt is later, since earlier ones are already counted here. Not an exact cutoff: a click can commit in the narrow gap between this value and the database read it describes, landing in both this response and a later live event — expect a rare, self-correcting double-count on the client, not an exact one-to-one split. That holds only when the same server clock stamped both values; across a multi-replica deployment, clock skew between the replica that recorded a click and the one that served this read can instead make a live event look already counted here when it isn't yet, dropping it until the next read." }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  /v1/analytics/campaigns/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: campaignAnalyticsDetail
      tags: [analytics]
      description: >-
        One campaign's total clicks and link count in the window, plus the
        same daily series and breakdowns shape as getLink/analyticsSummary.
        Readable — and reportable — even when the campaign is archived.
      summary: Campaign detail with click series and breakdowns
      parameters:
        - $ref: "#/components/parameters/Days"
        - $ref: "#/components/parameters/SeriesDays"
      responses:
        "200":
          description: Campaign detail
          content:
            application/json:
              schema:
                type: object
                required: [campaign, totalClicks, linkCount, series, breakdowns, days]
                properties:
                  campaign: { $ref: "#/components/schemas/Campaign" }
                  totalClicks: { type: integer }
                  linkCount: { type: integer }
                  asOf: { type: string, format: date-time, description: "Server time this response was read at. A live page keeps only the live-feed events whose committedAt is later, since earlier ones are already counted here. Not an exact cutoff: a click can commit in the narrow gap between this value and the database read it describes, landing in both this response and a later live event — expect a rare, self-correcting double-count on the client, not an exact one-to-one split. That holds only when the same server clock stamped both values; across a multi-replica deployment, clock skew between the replica that recorded a click and the one that served this read can instead make a live event look already counted here when it isn't yet, dropping it until the next read." }
                  series:
                    type: array
                    items: { $ref: "#/components/schemas/DayCount" }
                  breakdowns: { $ref: "#/components/schemas/Breakdowns" }
                  days: { type: integer }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /v1/campaigns:
    get:
      operationId: listCampaigns
      tags: [campaigns]
      description: >-
        Campaigns in the caller's workspace, newest first, paginated —
        shared by everyone in the workspace, not just their creator (EAS-73).
        Archived campaigns are hidden unless archived=1. q searches name
        server-side.
      summary: List the workspace's campaigns
      parameters:
        - name: q
          in: query
          schema: { type: string }
        - name: archived
          in: query
          description: "\"1\" lists archived campaigns instead of live ones"
          schema: { type: string, enum: ["1"] }
        - name: sort
          in: query
          description: >-
            Defaults to newest-updated. clicks/links order by a LEFT JOIN
            aggregate over each campaign's links, not a campaigns column, so
            they're still fully paginated (not a re-sort of one page).
          schema: { type: string, enum: [updated, name, clicks, links], default: updated }
        - 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 campaigns, sorted per the sort param
          content:
            application/json:
              schema:
                type: object
                required: [campaigns, total, limit, offset]
                properties:
                  campaigns:
                    type: array
                    items: { $ref: "#/components/schemas/Campaign" }
                  total: { type: integer }
                  limit: { type: integer }
                  offset: { type: integer }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    post:
      operationId: createCampaign
      tags: [campaigns]
      description: >-
        Creates a reusable UTM configuration. name must be unique within the
        caller's workspace (409 on conflict). Every UTM field is optional and
        defaults to '' — omitted fields never block creation, they're simply
        left blank in the snapshot links copy from this campaign.
      summary: Create a campaign
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                utmSource: { type: string }
                utmMedium: { type: string }
                utmCampaign: { type: string }
                utmTerm: { type: string }
                utmContent: { type: string }
      responses:
        "201":
          description: Campaign created
          content:
            application/json:
              schema:
                type: object
                required: [campaign]
                properties:
                  campaign: { $ref: "#/components/schemas/Campaign" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "409":
          description: A campaign with that name already exists in this workspace
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500":
          $ref: "#/components/responses/InternalServerError"

  /v1/campaigns/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: getCampaign
      tags: [campaigns]
      description: >-
        Not archived-filtered (unlike listCampaigns' default): an archived
        campaign stays readable forever so a link that still references it,
        and its analytics, keep resolving to something the caller can see.
      summary: Get a campaign
      responses:
        "200":
          description: The campaign
          content:
            application/json:
              schema:
                type: object
                required: [campaign]
                properties:
                  campaign: { $ref: "#/components/schemas/Campaign" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updateCampaign
      tags: [campaigns]
      description: >-
        Partial update — only provided fields change. Renaming re-checks the
        per-workspace uniqueness constraint (409 on conflict). Editing UTM
        values only affects links created (or re-pointed to this campaign)
        AFTER the change — every link created earlier keeps the immutable
        snapshot it was given at that time (EAS-73). archived=true stops the
        campaign from being selectable for new links without touching any
        link already using it; archived=false un-archives it.
      summary: Update a campaign, or archive/unarchive it
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                utmSource: { type: string }
                utmMedium: { type: string }
                utmCampaign: { type: string }
                utmTerm: { type: string }
                utmContent: { type: string }
                archived: { type: boolean }
      responses:
        "200":
          description: Updated campaign
          content:
            application/json:
              schema:
                type: object
                required: [campaign]
                properties:
                  campaign: { $ref: "#/components/schemas/Campaign" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: A campaign with that name already exists in this workspace
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500":
          $ref: "#/components/responses/InternalServerError"
    delete:
      operationId: archiveCampaign
      tags: [campaigns]
      description: >-
        Archives rather than hard-deleting (EAS-73): a campaign is the
        reporting dimension for every click already recorded against links
        that used it, and a hard delete would either orphan those links'
        campaignId or erase campaign-level history. Existing links and their
        analytics are completely unaffected — this only stops the campaign
        from being selectable for new links. Idempotent.
      summary: Archive a campaign (does not delete it)
      responses:
        "200":
          description: Archived
          content:
            application/json:
              schema:
                type: object
                required: [message, campaign]
                properties:
                  message: { type: string }
                  campaign: { $ref: "#/components/schemas/Campaign" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalServerError"

components:
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      bearerFormat: JWT
    docsBasic:
      type: http
      scheme: basic
      description: >-
        Gates /docs, /docs/openapi.yaml, /debug/webhooks/stats and /debug/clicks/stats.
        Credentials come from the DOCS_USER/DOCS_PASSWORD env vars; if either
        is unset the routes are not registered at all (404), never served
        open.
  parameters:
    Days:
      name: days
      in: query
      description: >-
        Analytics window in days; 0 means all-time (no cutoff); other values
        fall back to 14. 365 is the dashboard's own window — wide enough for
        its Month/Week tabs and to always have at least one full prior year
        for the Year tab, since bucketing a shorter window by month/year can
        collapse to a single bucket with nothing to draw a line between.
      schema:
        type: integer
        enum: [0, 7, 14, 30, 90, 365]
        default: 14
    SeriesDays:
      name: seriesDays
      in: query
      description: >-
        Windows just the day-series independently of days (which still scopes
        totalClicks/linkCount/breakdowns) — lets a caller get all-time totals
        and a shorter windowed series in one request. Omit to scope the
        series by days too, as when this parameter didn't exist.
      schema:
        type: integer
        enum: [0, 7, 14, 30, 90, 365]
  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 (EAS-31). Kept accurate under
            concurrent redirects by an atomic `clicks = clicks + n` DB
            update, never a read-modify-write. The redirect handler queues
            each click and a background writer flushes queued clicks (and
            bumps this counter) in batches at most every 500ms — so reading
            this field immediately after a burst of redirects can lag
            briefly before landing on the exact total.
        active: { type: boolean }
        archived: { type: boolean }
        trackConversions:
          type: boolean
          description: When true the redirect appends ?esrx_id=<click id> to the destination
        campaignId:
          type: string
          nullable: true
          description: >-
            The campaign this link was created from, or last re-pointed to
            (EAS-73). null for a link that never used one.
        utmSource: { type: string, description: "Immutable snapshot from the campaign at apply time; '' if no campaign was ever used" }
        utmMedium: { type: string, description: "Immutable snapshot from the campaign at apply time; '' if no campaign was ever used" }
        utmCampaign: { type: string, description: "Immutable snapshot from the campaign at apply time; '' if no campaign was ever used" }
        utmTerm: { type: string, description: "Immutable snapshot from the campaign at apply time; '' if no campaign was ever used" }
        utmContent: { type: string, description: "Immutable snapshot from the campaign at apply time; '' if no campaign was ever used" }
        expiresAt:
          type: string
          format: date-time
          nullable: true
        clickLimit:
          type: integer
          nullable: true
          description: >-
            EAS-85: configured hard cap on total normal clicks; null =
            unlimited. Enforced by an atomic Redis counter on the redirect
            path — this field (and clicks above) may lag the true live count
            briefly, same caveat as clicks.
        qrScanLimit:
          type: integer
          nullable: true
          description: >-
            EAS-85: configured hard cap on total QR scans, independent of
            clickLimit; null = unlimited.
        passwordProtected:
          type: boolean
          description: >-
            Whether a visitor-facing password gate is set on this link (see
            PATCH's password field). The password itself, and its hash, are
            never exposed by any endpoint — this boolean is the only signal.
        qrStyle:
          allOf: [{ $ref: "#/components/schemas/QRStyle" }]
          description: >-
            EAS-81: this link's persisted QR pattern/corner-style/colors.
            Reports the plain-square/black/white default when nothing's
            been customized yet — never null — so the customize panel can
            always render from this field directly. Set via PUT
            /v1/links/{id}/qr/style.
        qrLogoUrl:
          type: string
          description: >-
            EAS-81: URL of this link's persisted CUSTOM QR center logo, or
            "" if none is set. "" does not by itself mean the QR code has no
            logo at all — see qrLogoRemoved. Set via POST/DELETE
            /v1/links/{id}/qr/logo.
        retargeting:
          allOf: [{ $ref: "#/components/schemas/Retargeting" }]
          description: >-
            EAS-78: opt-in retargeting pixels. Always present; off with no
            pixels unless set via createLink, importLinks or updateLink.
        excludedIps:
          type: array
          items: { $ref: "#/components/schemas/ExcludedIp" }
          description: >-
            EAS-82: IPs/CIDR ranges excluded from this link's analytics —
            lets a team mark their own preview/test traffic so it doesn't
            inflate reported reach. Always present; empty unless set via
            setExcludedIps. A visitor whose client IP matches an entry has
            their click skipped entirely (never recorded, never counted
            toward clicks, never reported) — as if the request never
            happened, not merely filtered out of reports afterward.
        qrLogoRemoved:
          type: boolean
          description: >-
            EAS-81: every link's QR code renders with Easerix's small
            default logo until this is true. false (the default for every
            link, including ones created before EAS-81) means GET .../qr is
            currently applying either qrLogoUrl (if set) or that built-in
            default; true means an explicit DELETE /v1/links/{id}/qr/logo
            opted out, and the code renders with no logo at all until a new
            one is uploaded (which clears this back to false).
        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"
        screening:
          type: string
          enum: [clean, flagged, flagged_partial]
          description: >-
            EAS-76 screening verdict. clean = no flagged verdict on file for
            any destination this link can send a visitor to (including "never
            screened yet" — indistinguishable from "screened and clean").
            flagged = the link's own destinationUrl is blocked for every
            visitor. flagged_partial = only a routing rule's destination is
            blocked; most traffic still redirects normally.
        screeningSource:
          type: string
          description: >-
            The provider that flagged the destination (e.g. "urlhaus",
            "web_risk"). Present only when screening is flagged or
            flagged_partial.
        screeningReason:
          type: string
          description: >-
            Human-readable detail from the flagging provider (e.g. "urlhaus:
            listed url"). Present only when screening is flagged or
            flagged_partial. URLhaus checks the exact destination URL, not
            the bare host, so a shared host such as docs.google.com or
            github.com is not blocked because one unrelated URL on it is
            listed (EAS-136). "listed host ..." appears only when the
            screening service is run with host-level matching turned on,
            which is off by default.
    RetargetingPixel:
      type: object
      required: [provider, id]
      description: >-
        One structured pixel. Only these providers and ID shapes are
        accepted — never script, markup or URLs. id is trimmed; tiktok and
        google_ads ids are upper-cased. A provider/id pair may appear once per
        link.
      properties:
        provider:
          type: string
          enum: [meta, tiktok, google_ads, linkedin, pinterest]
        id:
          type: string
          description: >-
            meta 8-20 digits · tiktok 10-30 letters/digits · google_ads
            AW-<6-15 digits> · linkedin 4-12 digits (partner id) · pinterest
            10-20 digits (tag id).
    Retargeting:
      type: object
      required: [enabled, pixels]
      properties:
        enabled:
          type: boolean
          description: >-
            Master switch. Off by default. Only true when at least one pixel
            is configured and the customer attestation is recorded: a link
            that cannot fire reports false, and turning it on needs an
            attestation like any other off to on change. Turning it off
            keeps pixels.
        pixels:
          type: array
          maxItems: 10
          items: { $ref: "#/components/schemas/RetargetingPixel" }
    RetargetingInput:
      type: object
      description: >-
        EAS-78 retargeting config for createLink, importLinks and updateLink.
        On create/import omitted means off with no pixels. On update, omit the
        whole object to leave it unchanged; inside it each field is optional
        and left unchanged when omitted. enabled: true with no pixels (stored
        or sent) is rejected with 400, as is a pixels array that is invalid,
        has duplicates, or has more than 10 entries. Switching retargeting from
        off to on (on create and import: enabled: true) also requires
        attestation, checked server-side on every path; without a valid one the
        request is rejected with 400 (on import, that row is skipped with a
        row-level error). The attestation can only be given by a signed-in
        user's own session token: a token minted from an API key or an OAuth
        client is rejected with 400 whatever it sends, so retargeting cannot
        be turned on programmatically. Such a token can still set pixels,
        turn retargeting off and edit a link a signed-in user enabled.
        Turning it off, or editing the pixel list while it is on, needs no
        attestation.
      properties:
        enabled: { type: boolean }
        pixels:
          type: array
          maxItems: 10
          description: Replaces the whole list; [] clears it.
          items: { $ref: "#/components/schemas/RetargetingPixel" }
        attestation:
          $ref: "#/components/schemas/RetargetingAttestationInput"
    RetargetingAttestationInput:
      type: object
      required: [accepted, version]
      description: >-
        The customer's acceptance of the attestation from
        getRetargetingAttestation. accepted must be true and version must equal
        the version currently served there. Recorded with the user and time. It
        is not visitor consent.
      properties:
        accepted: { type: boolean }
        version: { type: string }
    QRStyle:
      type: object
      description: >-
        EAS-81: QR-safe styling knobs the branded QR renderer (qrgen)
        supports — pattern/cornerStyle stay within what a QR decoder
        expects structurally (finder-pattern detection ratios, contiguous
        modules) for any combination of those two fields alone. Colors
        aren't independently safe the same way: a low-contrast fg/bg[/bg2]
        combination can still fail to render scannably, especially
        composited with a logo — PUT .../qr/style verifies the full
        combination (see its own description) and, per EAS-207, saves it
        regardless, since a failure can be caused by the link's own short
        code/token rather than by these colors.
      required: [pattern, cornerStyle, fgColor, bgColor]
      properties:
        pattern:
          type: string
          enum: [square, dots, rounded, classy, extra-rounded]
          description: Data-module shape.
        cornerStyle:
          type: string
          enum: [square, rounded, dot, classy, extra-rounded]
          description: Finder-marker (corner) shape.
        fgColor: { type: string, description: "6-digit hex, e.g. #1a1a1a" }
        bgColor: { type: string, description: "6-digit hex, e.g. #ffffff" }
        bgColor2:
          type: string
          description: >-
            Optional 6-digit hex. When set, the background renders as a
            diagonal gradient from bgColor to bgColor2 instead of a solid
            fill. Omitted/"" means solid.
    Campaign:
      type: object
      description: >-
        A reusable, persisted UTM configuration (EAS-73) — both the autofill
        source when creating a link and the reporting dimension analytics
        rolls click data up by. Editing its UTM values only affects links
        created (or re-pointed to it) afterward; every link already using it
        keeps the snapshot it was given at that time.
      required: [id, name, utmSource, utmMedium, utmCampaign, utmTerm, utmContent, archived, createdAt, updatedAt]
      properties:
        id: { type: string }
        name: { type: string }
        utmSource: { type: string }
        utmMedium: { type: string }
        utmCampaign: { type: string }
        utmTerm: { type: string }
        utmContent: { type: string }
        archived:
          type: boolean
          description: >-
            Archived campaigns cannot be selected for new links, but stay
            fully readable and reportable for links/clicks already
            associated with them — never hard-deleted.
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    CampaignRollup:
      type: object
      description: One row of the campaign analytics rollup (GET /v1/analytics/campaigns).
      required: [campaignId, name, archived, linkCount, clicks]
      properties:
        campaignId: { type: string }
        name: { type: string }
        archived: { type: boolean }
        linkCount:
          type: integer
          description: Links currently pointed at this campaign (unwindowed — not affected by the `days` parameter)
        clicks:
          type: integer
          description: Non-bot clicks in the window across every link belonging to this campaign
    Click:
      type: object
      required: [referrer, referrerHost, userAgent, country, device, browser, os, at]
      properties:
        id:
          type: string
          description: >-
            EAS-83: present so a client can de-duplicate a click it may see
            both from this list and from the live click-stream SSE feed
            (GET /v1/links/{id}/stream) for the same click landing moments
            apart. Not required/historical clients may ignore it.
        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 }
        clicksUsed:
          type: integer
          description: >-
            EAS-83 follow-up: only present on GET /v1/links/{id}/stream
            events, never on recentClicks — the live Redis usage-cap count
            (same value GET /v1/links/{id}'s top-level clicksUsed returns),
            attached per click so a client can track the Click-limit stat
            live. Omitted if the usage limiter isn't wired server-side.
        qrScansUsed:
          type: integer
          description: Same as clicksUsed, for the QR-scan-limit counter.
        committedAt:
          type: string
          format: date-time
          description: >-
            Only present on GET /v1/links/{id}/stream events: when the server
            finished recording this click's batch (never the click's own `at`).
            Compare it with the asOf of a read to tell whether that read
            already includes this click.
    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. On input to setLinkRules, an id matching an
            existing rule on this link updates that rule in place (keeping
            the id and its analytics history); omit it, or send one that
            doesn't match, to create a new rule.
        kind:
          type: string
          enum: [geo, device, schedule, split]
        match:
          type: string
          description: 2-letter country for geo; mobile/tablet/desktop for device; unused for schedule/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
        startAt:
          type: string
          format: date-time
          description: >-
            Schedule rules only — visitors match while now is between startAt
            and endAt. Must be in the future (a window that's already started
            or already over is rejected, 400).
        endAt:
          type: string
          format: date-time
          description: Schedule rules only — must be in the future (a window that's already over is rejected, 400)
        active:
          type: boolean
          default: true
          description: >-
            A manual on/off switch independent of kind or schedule window
            (alarm semantics — the switch and the set time are separate).
            An inactive rule never matches, regardless of kind. An active
            schedule rule still only matches while now is between startAt
            and endAt. Defaults to true when omitted.
    ExcludedIp:
      type: object
      required: [cidr]
      properties:
        cidr:
          type: string
          description: >-
            An exact IP address or a CIDR range (e.g. "203.0.113.7" or
            "198.51.100.0/24"). Normalized server-side to CIDR notation — a
            bare IP is stored and returned as a /32 (or /128 for IPv6) host
            route. A range must be at least /24 for IPv4 or /64 for IPv6;
            broader ranges are rejected on setExcludedIps (400).
        label:
          type: string
          maxLength: 200
          description: Free-text note (e.g. "office", "QA VPN"); purely descriptive.
    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 }
        theme: { $ref: "#/components/schemas/BioTheme" }
        published: { type: boolean }
        views:
          type: integer
          description: >-
            Lifetime page views (EAS-37, EAS-141). Counted by GET /{code}
            when code starts with @ (the public bio page render) — the same
            unauthenticated redirect endpoint used for link clicks, routed
            here instead of to a destination URL. Owner previews
            (GET /v1/bio/{id}/preview) are not counted. Each view is a
            durable INSERT of its own row, written synchronously within the
            request, so concurrent viewers never contend on a shared
            counter and no view is lost to a crash; a periodic job folds
            those rows into the stored total. This field is the stored
            total plus any not-yet-folded rows, so it is exact whenever it
            is read. If the insert fails the error is logged and the page
            still renders.
        items:
          type: array
          items:
            type: object
            required: [id, label, url]
            properties:
              id: { type: string }
              label: { type: string }
              url: { type: string }
              icon: { type: integer, minimum: 0 }
              featured: { type: boolean }
        created: { type: string, format: date-time }
        updated: { type: string, format: date-time }
    BioCustomLayout:
      description: Only read when BioTheme.layout is "custom".
      type: object
      properties:
        align: { type: string, enum: [center, left] }
        avatar: { type: integer, description: "px, 40–104" }
        band: { type: integer, description: "px, 0–150 (0 = no band)" }
        arch: { type: boolean }
        rule: { type: boolean }
    BioTheme:
      description: >-
        Visual customization for a bio page — stored as one JSON blob so a
        new knob is a struct change, not a migration.
      type: object
      properties:
        preset: { type: string }
        layout: { type: string, enum: [classic, arch, sideby, banner, compact, custom] }
        background: { type: string, description: "#rrggbb" }
        text: { type: string, description: "#rrggbb" }
        accent: { type: string, description: "#rrggbb" }
        bgKind: { type: string, enum: [solid, gradient, mesh, marble, image] }
        bgImageUrl: { type: string }
        bgFit: { type: string, enum: [cover, contain, stretch] }
        bgScrim: { type: number }
        bgZoom: { type: number, description: "% of the image's natural width, when bgFit is cover" }
        bgPosX: { type: number, description: "0–100" }
        bgPosY: { type: number, description: "0–100" }
        shape: { type: string, enum: [sharp, round, pill] }
        fill: { type: string, enum: [solid, outline, soft, glass, liquid] }
        shadow: { type: string, enum: [none, soft, lift, hard] }
        font: { type: string }
        bold: { type: boolean, description: Mega-card treatment for the featured item. }
        avatarUrl: { type: string }
        customLayout: { $ref: "#/components/schemas/BioCustomLayout" }
        socials:
          type: object
          additionalProperties: { type: string }
    BioItemDetail:
      description: >-
        A single bio page item, as returned by the item-level CRUD endpoints
        (POST/PATCH/DELETE /v1/bio/{id}/items). Includes position, unlike
        the items entries embedded in BioPage.
      type: object
      required: [id, label, url, position]
      properties:
        id: { type: string }
        label: { type: string }
        url: { type: string }
        position: { type: integer, minimum: 0 }
        icon: { type: integer, minimum: 0 }
        featured: { type: boolean }
    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
        droppedCount:
          type: integer
          description: >-
            Legacy: counted deliveries lost to a full in-memory delivery queue before
            delivery became a durable outbox. Nothing increments it anymore -
            always 0 on webhooks created after the outbox migration, frozen at its
            prior value on older ones. Kept for historical continuity only.
        secret:
          type: string
          description: Present only in the creation and regenerate-secret responses; the full value is shown once and never re-returned.
        secretFingerprint:
          type: string
          description: Last 6 characters of the current secret, present whenever `secret` is not, so the configured key can be visually confirmed without re-exposing it.
        created: { type: string, format: date-time }
    WebhookDelivery:
      type: object
      required: [id, webhookId, event, status, attemptCount, maxAttempts, nextAttemptAt, created]
      properties:
        id:
          type: string
          description: >-
            Stable for this delivery's whole lifetime, including across
            retries and a replay — this is the same value sent as the
            X-Easerix-Delivery header on every attempt, the idempotency key
            a receiving endpoint should dedupe on.
        webhookId: { type: string }
        event: { type: string }
        status: { type: string, enum: [pending, delivering, succeeded, dead] }
        attemptCount: { type: integer }
        maxAttempts: { type: integer }
        lastStatusCode:
          type: integer
          description: HTTP status of the most recent attempt (0 = none yet, or a network/dial error)
        lastError: { type: string }
        nextAttemptAt: { type: string, format: date-time }
        created: { type: string, format: date-time }
    ClickStats:
      type: object
      required: [writer, recount]
      properties:
        writer:
          type: object
          required:
            [queueDepth, queueCapacity, droppedClicks, overflowPending, flushFailures, lostClicks]
          properties:
            queueDepth: { type: integer }
            queueCapacity: { type: integer }
            droppedClicks:
              type: integer
              description: >-
                Clicks that lost their row to a full queue. Non-bot ones are
                still added to links.clicks.
            overflowPending:
              type: integer
              description: Dropped clicks counted but not yet written by a flush.
            flushFailures:
              type: integer
              description: Flushes whose transaction rolled back.
            lostClicks:
              type: integer
              description: Clicks in the rolled-back flushes.
        recount:
          type: object
          required: [scanned, behind, ahead, repaired, failedRuns]
          properties:
            scanned: { type: integer, description: Links compared. }
            behind: { type: integer, description: links.clicks below the recorded click rows. }
            ahead:
              type: integer
              description: links.clicks above the recorded rows. Never written.
            repaired: { type: integer, description: Links raised to match their rows. }
            failedRuns: { type: integer, description: Ticks that errored or timed out. }
    WebhookDispatcherStats:
      type: object
      description: >-
        In-process observability snapshot for one dispatcher — reset to zero
        on restart, not aggregated across replicas, except pendingCount and
        oldestPendingAgeSeconds which query Postgres live and are therefore
        accurate regardless of how many replicas are involved.
      required:
        [
          succeeded, failed, retried, dead, deferred, avgDeliveryLatencyMs,
          avgClaimLatencyMs, workerUtilization, pendingCount,
          oldestPendingAgeSeconds, destinationFailureRates,
        ]
      properties:
        succeeded: { type: integer }
        failed: { type: integer }
        retried: { type: integer }
        dead: { type: integer }
        deferred:
          type: integer
          description: Rate-limit deferrals — not a failure, not a retry.
        avgDeliveryLatencyMs: { type: number }
        avgClaimLatencyMs: { type: number }
        workerUtilization:
          type: number
          description: In-flight delivery groups / delivery concurrency capacity, 0..1.
        pendingCount: { type: integer }
        oldestPendingAgeSeconds: { type: number }
        destinationFailureRates:
          type: object
          description: >-
            Destination host -> failures/attempts observed by this process
            since it started (0..1 per host).
          additionalProperties: { type: number }
    DayCount:
      type: object
      required: [date, clicks]
      properties:
        date:
          type: string
          description: UTC day, YYYY-MM-DD
        clicks: { type: integer }
        activeLinks:
          type: integer
          description: >-
            Distinct links that received at least one non-bot click on this
            day (or, for the single-link/campaign-scoped series, always 1
            when there's a click and the scope is one link) — the
            denominator for a genuine "avg clicks per active link" figure,
            as opposed to dividing by the account's current total link
            count.
    ConversionDayCount:
      type: object
      required: [date, count, revenueCents]
      properties:
        date:
          type: string
          description: UTC day, YYYY-MM-DD
        count: { type: integer }
        revenueCents: { 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: >-
        Resource not found or not owned by the caller. A malformed (non-UUID)
        id in the path is reported the same way, never as a 500.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    TooManyRequests:
      description: >-
        Rate limit exceeded (EAS-77, GCRA). Retry-After gives the number of
        seconds to wait before retrying.
      headers:
        Retry-After:
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RequestTimeout:
      description: >-
        The request body did not arrive in time. Batch and import read the
        start of the body to size the request before it is rate limited, and
        give up after 10 seconds. The connection is closed; retry the request.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    InternalServerError:
      description: >-
        Database error — the operation did not complete. Never returned as a
        false 200 (EAS-30/50/51/52): every write/read on this route checks
        the underlying GORM error and fails closed instead of reporting
        success with empty/zero/silently-unsaved data.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
