openapi: 3.1.0
info:
  title: Operlyx Links API
  version: "1.0.0"
  description: |
    Public REST API for Operlyx Links — create short links, fetch QR assets, and
    read click reports programmatically.

    ## Access
    The API is available on the **Pro** and **Enterprise** plans. Free and
    lapsed-Pro workspaces are rejected with `403 api_access_forbidden`.

    ## Authentication
    Create an API key in your dashboard settings and send it as a bearer token on
    every request. Keys are shown once at creation — store them securely.

    ```
    Authorization: Bearer olx_live_<your_key>
    ```

    ## Rate limits
    Link creation is rate-limited per workspace, per minute (Pro: 60/min;
    Enterprise: a high default, adjustable per account). Exceeding it returns
    `429 rate_limited` with a `Retry-After` header (seconds).

    ## Errors
    Errors use a stable envelope: `{ "error": "<code>" }`, sometimes with extra
    fields (`issues` for validation, `limit` for quota). Codes are documented per
    endpoint and in the `Error` schema.
  contact:
    name: Operlyx support
    email: support@operlyx.com
    url: https://link.operlyx.com/contact
servers:
  - url: https://api.link.operlyx.com
    description: Production
security:
  - bearerAuth: []
tags:
  - name: Links
    description: Create and manage short links.
  - name: QR
    description: Download QR assets for a link.
  - name: Reports
    description: Click analytics for a link.
  - name: Account
    description: Workspace usage.
paths:
  /api/v1/links:
    post:
      tags: [Links]
      summary: Create a short link
      description: |
        Creates a short link. Omit `slug` for an auto-generated random slug, or
        supply one to create a custom slug under your workspace handle
        (`{host}/{handle}/{slug}`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateLinkInput"
            examples:
              random:
                summary: Auto-generated slug
                value:
                  targetUrl: https://example.com/campaign
              custom:
                summary: Custom slug with expiry
                value:
                  targetUrl: https://example.com/spring-sale
                  slug: spring-sale
                  expiresAt: "2026-12-31T23:59:59.000Z"
      responses:
        "201":
          description: Link created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Link"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            Access forbidden. `api_access_forbidden` (plan lacks API access),
            `ip_not_allowed` (caller IP not in the Enterprise allowlist), or
            `link_quota_exceeded` (workspace at its link limit; `limit` echoes it).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                quota:
                  value: { error: link_quota_exceeded, limit: 1000 }
                access:
                  value: { error: api_access_forbidden }
        "409":
          description: The requested custom slug is already taken (or tombstoned).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example: { error: slug_taken }
        "429":
          $ref: "#/components/responses/RateLimited"
    get:
      tags: [Links]
      summary: List your links
      description: |
        Returns links newest-first, keyset-paginated. Pass the returned
        `nextCursor` back as `?cursor=` to fetch the next page; a `null`
        `nextCursor` means there are no more pages.
      parameters:
        - name: limit
          in: query
          description: Page size (1–1000, default 100).
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 1000
            default: 100
        - name: cursor
          in: query
          description: Opaque pagination cursor from a previous response's `nextCursor`.
          required: false
          schema:
            type: string
      responses:
        "200":
          description: A page of links.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LinkList"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/links/{id}:
    parameters:
      - $ref: "#/components/parameters/LinkId"
    get:
      tags: [Links]
      summary: Fetch a single link
      responses:
        "200":
          description: The link.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Link"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      tags: [Links]
      summary: Update a link
      description: Update the destination and/or expiration. At least one field is required.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateLinkInput"
            examples:
              retarget:
                summary: Change destination
                value: { targetUrl: https://example.com/new-destination }
              clearExpiry:
                summary: Remove expiration
                value: { expiresAt: null }
      responses:
        "200":
          description: The updated link.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Link"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags: [Links]
      summary: Delete (retire) a link
      description: |
        Soft-deletes the link. The slug is tombstoned forever and can never be
        re-registered, so printed QR codes can't be hijacked. Returns `204` with
        no body.
      responses:
        "204":
          description: Link retired. No content.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/links/{id}/qr.svg:
    parameters:
      - $ref: "#/components/parameters/LinkId"
    get:
      tags: [QR]
      summary: Download the QR as SVG
      description: Free-plan QR assets include a footer watermark; Pro/Enterprise are unwatermarked.
      responses:
        "200":
          description: The QR code as SVG.
          content:
            image/svg+xml:
              schema:
                type: string
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/links/{id}/qr.png:
    parameters:
      - $ref: "#/components/parameters/LinkId"
    get:
      tags: [QR]
      summary: Download the QR as PNG
      responses:
        "200":
          description: The QR code as PNG.
          content:
            image/png:
              schema:
                type: string
                format: binary
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/links/{id}/report:
    parameters:
      - $ref: "#/components/parameters/LinkId"
    get:
      tags: [Reports]
      summary: Combined report for a link
      description: |
        Returns lifetime totals plus daily and dimension breakdowns for a window.
        Use `from`/`to` (inclusive, `YYYY-MM-DD`) to slice; `from` is clamped to
        the plan's retention cutoff (data older than retention is gone). The
        effective `from` and `to` are echoed in the response.
      parameters:
        - name: from
          in: query
          description: Inclusive start date (YYYY-MM-DD). Clamped to the retention cutoff.
          required: false
          schema:
            type: string
            format: date
        - name: to
          in: query
          description: Inclusive end date (YYYY-MM-DD).
          required: false
          schema:
            type: string
            format: date
      responses:
        "200":
          description: The report.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Report"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/usage:
    get:
      tags: [Account]
      summary: Current usage
      description: |
        Returns your workspace's current usage — live links, clicks this calendar
        month, and API calls this month (Enterprise metering; `null` if not yet
        available). `monthlyClickLimit` is `null` on plans with no cap.
      responses:
        "200":
          description: Usage snapshot.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Usage"
              example:
                plan: enterprise
                linksLive: 4210
                clicksThisMonth: 1875321
                monthlyClickLimit: null
                apiCallsThisMonth: 90422
        "401":
          $ref: "#/components/responses/Unauthorized"
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: An API key created in dashboard settings, prefixed `olx_live_`.
  parameters:
    LinkId:
      name: id
      in: path
      required: true
      description: The link's id.
      schema:
        type: string
  schemas:
    Link:
      type: object
      description: A short link. Workspace ownership is never exposed.
      required:
        [id, slug, handle, shortUrl, targetUrl, status, expiresAt, totalClicks, lastClickAt, createdAt, updatedAt]
      properties:
        id:
          type: string
        slug:
          type: string
          description: The short slug (last path segment).
        handle:
          type: [string, "null"]
          description: The workspace handle a custom slug lives under, or null for root/random slugs.
        shortUrl:
          type: string
          format: uri
        targetUrl:
          type: string
          format: uri
        status:
          type: string
          enum: [active, disabled]
        expiresAt:
          type: [string, "null"]
          format: date-time
        totalClicks:
          type: integer
        lastClickAt:
          type: [string, "null"]
          format: date-time
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
      example:
        id: 8f2c1d3e-4b5a-6789-abcd-ef0123456789
        slug: spring-sale
        handle: acme
        shortUrl: https://l.operlyx.com/acme/spring-sale
        targetUrl: https://example.com/spring-sale
        status: active
        expiresAt: "2026-12-31T23:59:59.000Z"
        totalClicks: 1204
        lastClickAt: "2026-07-16T09:41:00.000Z"
        createdAt: "2026-07-01T12:00:00.000Z"
        updatedAt: "2026-07-10T08:30:00.000Z"
    CreateLinkInput:
      type: object
      required: [targetUrl]
      properties:
        targetUrl:
          type: string
          format: uri
          description: Destination URL. Must be http(s).
        slug:
          type: string
          minLength: 3
          maxLength: 64
          pattern: "^[A-Za-z0-9_-]+$"
          description: Optional custom slug (letters, numbers, '-', '_'). Reserved words are rejected. Omit for a random slug.
        expiresAt:
          type: string
          format: date-time
          description: Optional ISO 8601 expiration.
    UpdateLinkInput:
      type: object
      minProperties: 1
      description: At least one field must be supplied.
      properties:
        targetUrl:
          type: string
          format: uri
        expiresAt:
          type: [string, "null"]
          format: date-time
          description: New expiration, or null to clear it.
    LinkList:
      type: object
      required: [links, nextCursor]
      properties:
        links:
          type: array
          items:
            $ref: "#/components/schemas/Link"
        nextCursor:
          type: [string, "null"]
          description: Cursor for the next page, or null if this is the last page.
    DailyPoint:
      type: object
      required: [date, clicks]
      properties:
        date:
          type: string
          format: date
        clicks:
          type: integer
    DimensionPoint:
      type: object
      required: [value, clicks]
      properties:
        value:
          type: string
        clicks:
          type: integer
    Report:
      type: object
      required:
        [totalClicks, lastClickAt, retentionDays, from, to, windowClicks, daily, countries, devices, referrers]
      properties:
        totalClicks:
          type: integer
          description: Lifetime clicks (never affected by retention).
        lastClickAt:
          type: [string, "null"]
          format: date-time
        retentionDays:
          type: integer
        from:
          type: string
          format: date
          description: Effective (clamped) start date of the window.
        to:
          type: [string, "null"]
          format: date
          description: Effective end date, or null when open-ended.
        windowClicks:
          type: integer
        daily:
          type: array
          items:
            $ref: "#/components/schemas/DailyPoint"
        countries:
          type: array
          items:
            $ref: "#/components/schemas/DimensionPoint"
        devices:
          type: array
          items:
            $ref: "#/components/schemas/DimensionPoint"
        referrers:
          type: array
          items:
            $ref: "#/components/schemas/DimensionPoint"
    Usage:
      type: object
      required: [plan, linksLive, clicksThisMonth, monthlyClickLimit, apiCallsThisMonth]
      properties:
        plan:
          type: string
          enum: [free, pro, enterprise]
        linksLive:
          type: integer
        clicksThisMonth:
          type: integer
        monthlyClickLimit:
          type: [integer, "null"]
          description: Fair-use monthly click ceiling, or null when uncapped.
        apiCallsThisMonth:
          type: [integer, "null"]
          description: API calls this month (Enterprise metering), or null if unavailable.
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: |
            Stable machine-readable code. Known values: `unauthorized`,
            `invalid_api_key`, `api_access_forbidden`, `ip_not_allowed`,
            `invalid_request`, `rate_limited`, `link_quota_exceeded`,
            `slug_taken`, `not_found`.
        issues:
          type: array
          description: Present on `invalid_request` — field-level validation issues.
          items:
            type: object
        limit:
          type: integer
          description: Present on `link_quota_exceeded` — the workspace's link limit.
  responses:
    Unauthorized:
      description: Missing or invalid API key (`unauthorized` or `invalid_api_key`).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example: { error: unauthorized }
    NotFound:
      description: The link does not exist (or isn't yours).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example: { error: not_found }
    InvalidRequest:
      description: The request body failed validation.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: invalid_request
            issues:
              - path: [targetUrl]
                message: Target URL must be an http(s) URL
    RateLimited:
      description: Too many link-create requests. Retry after the `Retry-After` header (seconds).
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example: { error: rate_limited }
