openapi: 3.1.0
info:
  title: buygolinks.com API
  version: "1.0.0"
  summary: Branded short-link service with Amazon affiliate geo-routing.
  description: |
    The buygolinks API lets you programmatically manage short links, Amazon
    affiliate tags, collections, tracking pixels, and read click analytics.

    **Base URLs**
    - API: `https://app.buygolinks.com`
    - Short-link resolver (public, no auth): `https://buygolinks.com/{code}`

    **Authentication**
    All `/api/*` endpoints require a Bearer token. Two token shapes are
    accepted:

    1. **Personal access token** — recommended for any programmatic use.
       Format: `bgl_<24-base62>`. Create via `POST /api/api-keys`; the raw
       token is returned **exactly once**. We store SHA-256(token) only.
    2. **Supabase JWT** — issued to the SPA after login. Short-lived; not
       intended for backend integrations.

    Pass either as: `Authorization: Bearer <token>`.

    **Errors**
    All error responses are JSON of shape `{ "error": "<code>" }`. Common
    codes: `unauthorized` (401), `forbidden` (403), `not_found` (404),
    `bad_json` / `invalid_url` / `tag_required` / `no_changes` (400).
  contact:
    name: buygolinks support
    url: https://app.buygolinks.com
servers:
  - url: https://app.buygolinks.com
    description: Production API
security:
  - bearerAuth: []
tags:
  - name: Links
    description: Short link CRUD.
  - name: Statistics
    description: Aggregated click analytics.
  - name: Amazon tags
    description: Per-store Amazon Associates tags used by the geo-router.
  - name: Collections
    description: Group links into folders, optionally exposed as a rotator alias.
  - name: Tracking pixels
    description: Meta/TikTok/Google pixels fired on the deep-link interstitial.
  - name: API keys
    description: Personal access tokens for programmatic access.

paths:
  /api/links:
    get:
      tags: [Links]
      summary: List your links
      parameters:
        - in: query
          name: include_archived
          schema: { type: string, enum: ["1"] }
          description: Pass `1` to include archived links. Default omits them.
      responses:
        "200":
          description: Links with per-link click counts and current usage.
          content:
            application/json:
              schema:
                type: object
                properties:
                  links:
                    type: array
                    items: { $ref: "#/components/schemas/Link" }
                  short_domain: { type: string, example: "buygolinks.com" }
                  usage: { $ref: "#/components/schemas/Usage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Links]
      summary: Create a short link
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [destination_url]
              properties:
                destination_url:
                  type: string
                  format: uri
                  description: Original URL (http/https only). Amazon `/dp/{ASIN}` URLs are auto-detected.
                  example: "https://www.amazon.com/dp/B08N5WRWNW"
                title:
                  type: string
                  maxLength: 200
                source_label:
                  type: string
                  maxLength: 60
                  description: Slug used in `ascsubtag` for attribution. Lowercased + sanitized server-side.
      responses:
        "200":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  link: { $ref: "#/components/schemas/Link" }
                  short_url: { type: string, example: "https://buygolinks.com/aB3xY7zQ" }
                  short_domain: { type: string }
        "400":
          description: Validation failed. `error` is one of `url_required`, `invalid_url`, `invalid_url_scheme`, `bad_json`.
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/links/{id}:
    patch:
      tags: [Links]
      summary: Edit a link
      parameters: [{ $ref: "#/components/parameters/LinkId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string, maxLength: 200 }
                source_label: { type: string, maxLength: 60 }
                archived: { type: boolean, description: "true archives, false un-archives." }
      responses:
        "200":
          description: Updated link.
          content:
            application/json:
              schema: { type: object, properties: { link: { $ref: "#/components/schemas/Link" } } }
        "400": { description: "`no_changes` — patch body was empty." }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Links]
      summary: Delete a link (cascades click history)
      parameters: [{ $ref: "#/components/parameters/LinkId" }]
      responses:
        "200": { description: "Deleted.", content: { application/json: { schema: { type: object, properties: { ok: { type: boolean } } } } } }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/links/{id}/collection:
    post:
      tags: [Links, Collections]
      summary: Assign a link to (or detach from) a collection
      parameters: [{ $ref: "#/components/parameters/LinkId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                collection_id:
                  type: [integer, "null"]
                  description: Numeric collection id. Pass `null` to detach.
      responses:
        "200": { description: "Updated link.", content: { application/json: { schema: { type: object, properties: { link: { $ref: "#/components/schemas/Link" } } } } } }
        "400": { description: "`invalid_collection` — collection does not exist or is not yours." }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/stats/overview:
    get:
      tags: [Statistics]
      summary: Account-wide aggregated stats
      parameters:
        - in: query
          name: days
          schema: { type: integer, minimum: 1, maximum: 90, default: 14 }
          description: Time window in days. Clamped to 1–90.
      responses:
        "200":
          description: Everything the Statistics page needs in one round trip.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StatsOverview" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/stats/link/{id}:
    get:
      tags: [Statistics]
      summary: Per-link aggregated stats
      parameters:
        - { $ref: "#/components/parameters/LinkId" }
        - in: query
          name: days
          schema: { type: integer, minimum: 1, maximum: 90, default: 14 }
      responses:
        "200":
          description: Per-link stats.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LinkStats" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/amazon-tags:
    get:
      tags: [Amazon tags]
      summary: List Amazon affiliate tags
      responses:
        "200":
          description: Your tags plus the catalog of valid stores.
          content:
            application/json:
              schema:
                type: object
                properties:
                  tags:
                    type: array
                    items: { $ref: "#/components/schemas/AmazonTag" }
                  stores:
                    type: object
                    additionalProperties: { type: string }
                    description: "ISO2 → Amazon host map (e.g. `US → www.amazon.com`)."
    post:
      tags: [Amazon tags]
      summary: Create or update a tag (upsert on `(owner, store)`)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [store, tag]
              properties:
                store: { type: string, description: "ISO2 country code, uppercased.", example: "US" }
                tag: { type: string, description: "Amazon Associates tag. Sanitized to `[a-z0-9_-]`, max 60.", example: "mybrand-20" }
                active: { type: boolean, default: true }
                geo_enabled: { type: boolean, default: true, description: "If true, the resolver will route visitors to this store when their country matches." }
      responses:
        "200": { description: "Created or updated.", content: { application/json: { schema: { type: object, properties: { tag: { $ref: "#/components/schemas/AmazonTag" } } } } } }
        "400": { description: "`invalid_store` or `tag_required`." }

  /api/amazon-tags/{id}:
    patch:
      tags: [Amazon tags]
      summary: Update a tag
      parameters: [{ $ref: "#/components/parameters/TagId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                tag: { type: string }
                active: { type: boolean }
                geo_enabled: { type: boolean }
      responses:
        "200": { description: "Updated.", content: { application/json: { schema: { type: object, properties: { tag: { $ref: "#/components/schemas/AmazonTag" } } } } } }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Amazon tags]
      summary: Delete a tag
      parameters: [{ $ref: "#/components/parameters/TagId" }]
      responses:
        "200": { description: "Deleted." }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/collections:
    get:
      tags: [Collections]
      summary: List collections
      responses:
        "200":
          description: Collections with link + click counts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  collections:
                    type: array
                    items: { $ref: "#/components/schemas/Collection" }
    post:
      tags: [Collections]
      summary: Create a collection
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 80 }
                description: { type: string, maxLength: 280 }
                color:
                  type: string
                  description: Hex color from a fixed palette; invalid values fall back to `#ef4444`.
                  example: "#3b82f6"
                starred: { type: boolean, default: false }
                rotator_enabled: { type: boolean, default: false }
                rotator_alias:
                  type: string
                  description: "8–24 chars `[A-Za-z0-9_-]`. Auto-generated if omitted while `rotator_enabled` is true."
      responses:
        "200": { description: "Created.", content: { application/json: { schema: { type: object, properties: { collection: { $ref: "#/components/schemas/Collection" } } } } } }
        "400": { description: "`name_required` or `bad_json`." }
        "409": { description: "`alias_taken` — could not allocate a unique rotator alias after 5 attempts." }

  /api/collections/{id}:
    get:
      tags: [Collections]
      summary: Collection detail (meta + member links + aggregated stats)
      parameters:
        - { $ref: "#/components/parameters/CollectionId" }
        - in: query
          name: days
          schema: { type: integer, minimum: 1, maximum: 90, default: 14 }
      responses:
        "200": { description: "Collection detail page payload." }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Collections]
      summary: Update a collection
      parameters: [{ $ref: "#/components/parameters/CollectionId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 80 }
                description: { type: string, maxLength: 280 }
                color: { type: string }
                starred: { type: boolean }
                rotator_enabled: { type: boolean }
                rotator_alias: { type: string }
      responses:
        "200": { description: "Updated." }
        "400": { description: "`invalid_alias` or `no_changes`." }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "`alias_taken`." }
    delete:
      tags: [Collections]
      summary: Delete a collection (member links are detached, not deleted)
      parameters: [{ $ref: "#/components/parameters/CollectionId" }]
      responses:
        "200": { description: "Deleted." }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/tracking-pixels:
    get:
      tags: [Tracking pixels]
      summary: List tracking pixels + supported platforms
      responses:
        "200":
          description: Pixels + platform catalog (label/placeholder/help text).
          content:
            application/json:
              schema:
                type: object
                properties:
                  pixels: { type: array, items: { $ref: "#/components/schemas/TrackingPixel" } }
                  platforms: { type: object, additionalProperties: true }
    post:
      tags: [Tracking pixels]
      summary: Add a tracking pixel
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [platform, pixel_id]
              properties:
                platform: { type: string, description: "One of the keys returned by `GET /api/tracking-pixels` under `platforms`." }
                pixel_id: { type: string }
                label: { type: string, maxLength: 80 }
                active: { type: boolean, default: true }
      responses:
        "200": { description: "Created." }
        "400": { description: "`invalid_platform` or `invalid_pixel_id`." }

  /api/tracking-pixels/{id}:
    patch:
      tags: [Tracking pixels]
      summary: Update a pixel
      parameters: [{ $ref: "#/components/parameters/PixelId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                pixel_id: { type: string }
                label: { type: string }
                active: { type: boolean }
      responses:
        "200": { description: "Updated." }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Tracking pixels]
      summary: Delete a pixel
      parameters: [{ $ref: "#/components/parameters/PixelId" }]
      responses:
        "200": { description: "Deleted." }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/api-keys:
    get:
      tags: [API keys]
      summary: List your personal access tokens (token hash never returned)
      responses:
        "200":
          description: Tokens with metadata.
          content:
            application/json:
              schema:
                type: object
                properties:
                  keys: { type: array, items: { $ref: "#/components/schemas/ApiKey" } }
    post:
      tags: [API keys]
      summary: Create a new personal access token
      description: |
        Returns the raw token **exactly once**. Store it immediately — there
        is no way to retrieve it again. We store SHA-256(token) only.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                label: { type: string, maxLength: 80, default: "Untitled key" }
      responses:
        "200":
          description: Created. Token shown ONCE.
          content:
            application/json:
              schema:
                type: object
                properties:
                  key: { $ref: "#/components/schemas/ApiKey" }
                  token:
                    type: string
                    example: "bgl_aB3xY7zQmN9pK2rT5sW8vCdE"
                    description: The full token. Send as `Authorization: Bearer <token>`. Never returned again.

  /api/api-keys/{id}:
    delete:
      tags: [API keys]
      summary: Revoke an API key
      parameters: [{ $ref: "#/components/parameters/KeyId" }]
      responses:
        "200": { description: "Revoked." }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/domains:
    get:
      tags: [Domains]
      summary: List custom domains
      description: |
        List the user's branded short-link domains, plan tier, plan domain limit,
        and the CNAME target each domain must point at.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  domains: { type: array, items: { $ref: "#/components/schemas/CustomDomain" } }
                  plan: { type: string, example: small }
                  domain_limit: { type: integer, example: 1 }
                  cname_target: { type: string, example: buygolinks.com }
    post:
      tags: [Domains]
      summary: Register a custom domain
      description: |
        Creates a Cloudflare for SaaS custom hostname and stores it. The user
        must then point a CNAME record at `cname_target` for SSL/validation to
        complete. Free plan returns `402 upgrade_required`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [hostname]
              properties:
                hostname: { type: string, example: go.acme.com }
      responses:
        "200":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  domain: { $ref: "#/components/schemas/CustomDomain" }
                  cname_target: { type: string }
                  instructions: { type: string }
        "400": { description: "invalid_hostname or reserved_hostname" }
        "402": { description: "upgrade_required or plan_limit_reached" }
        "409": { description: "hostname_taken" }
        "502": { description: "cloudflare_error" }

  /api/domains/{id}:
    delete:
      tags: [Domains]
      summary: Remove a custom domain
      parameters: [{ $ref: "#/components/parameters/DomainId" }]
      responses:
        "200": { description: "Removed." }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/domains/{id}/verify:
    post:
      tags: [Domains]
      summary: Force re-check domain status against Cloudflare
      parameters: [{ $ref: "#/components/parameters/DomainId" }]
      responses:
        "200":
          description: Refreshed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  domain: { $ref: "#/components/schemas/CustomDomain" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: "bgl_<24 base62> or Supabase JWT"

  parameters:
    LinkId:
      in: path
      name: id
      required: true
      schema: { type: integer }
    TagId:
      in: path
      name: id
      required: true
      schema: { type: integer }
    CollectionId:
      in: path
      name: id
      required: true
      schema: { type: integer }
    PixelId:
      in: path
      name: id
      required: true
      schema: { type: integer }
    KeyId:
      in: path
      name: id
      required: true
      schema: { type: integer }
    DomainId:
      in: path
      name: id
      required: true
      schema: { type: integer }

  responses:
    Unauthorized:
      description: Missing or invalid bearer token.
      content: { application/json: { schema: { type: object, properties: { error: { type: string, example: "unauthorized" } } } } }
    Forbidden:
      description: Resource exists but is not owned by the caller.
      content: { application/json: { schema: { type: object, properties: { error: { type: string, example: "forbidden" } } } } }
    NotFound:
      description: Resource does not exist.
      content: { application/json: { schema: { type: object, properties: { error: { type: string, example: "not_found" } } } } }

  schemas:
    Link:
      type: object
      properties:
        id: { type: integer }
        code: { type: string, description: "8-char case-sensitive base62.", example: "aB3xY7zQ" }
        destination_url: { type: string, format: uri }
        asin: { type: [string, "null"], description: "Auto-extracted Amazon ASIN, if the destination is an Amazon `/dp/` URL." }
        title: { type: [string, "null"] }
        source_label: { type: [string, "null"] }
        product_image_url: { type: [string, "null"], description: "Rehosted Amazon product image, served from our Supabase Storage bucket." }
        collection_id: { type: [integer, "null"] }
        created_at: { type: string, format: date-time }
        archived_at: { type: [string, "null"], format: date-time }
        clicks: { type: integer, description: "Lifetime click count. Only present on list responses." }

    Usage:
      type: object
      properties:
        plan: { type: string }
        links: { type: object, properties: { used: { type: integer }, limit: { type: [integer, "null"] } } }
        clicks_30d: { type: object, properties: { used: { type: integer }, limit: { type: [integer, "null"] } } }

    AmazonTag:
      type: object
      properties:
        id: { type: integer }
        store: { type: string, example: "US" }
        tag: { type: string, example: "mybrand-20" }
        active: { type: boolean }
        geo_enabled: { type: boolean }
        created_at: { type: string, format: date-time }

    Collection:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        description: { type: [string, "null"] }
        color: { type: string, example: "#3b82f6" }
        starred: { type: boolean }
        rotator_enabled: { type: boolean }
        rotator_alias: { type: [string, "null"], description: "If set, `https://buygolinks.com/c/{alias}` rotates among the collection's members." }
        created_at: { type: string, format: date-time }
        links: { type: integer }
        clicks: { type: integer }

    TrackingPixel:
      type: object
      properties:
        id: { type: integer }
        platform: { type: string, example: "meta" }
        pixel_id: { type: string }
        label: { type: [string, "null"] }
        active: { type: boolean }
        created_at: { type: string, format: date-time }

    ApiKey:
      type: object
      properties:
        id: { type: integer }
        label: { type: string }
        prefix: { type: string, description: "First 8 chars of the random portion, for UI identification.", example: "aB3xY7zQ" }
        last_used_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        revoked_at: { type: [string, "null"], format: date-time }

    CustomDomain:
      type: object
      properties:
        id: { type: integer }
        hostname: { type: string, example: go.acme.com }
        cf_hostname_id: { type: [string, "null"] }
        status:
          type: string
          enum: [pending, active, failed, blocked]
        ssl_status: { type: [string, "null"], example: active }
        verification_errors: { type: [array, "null"], items: { type: object } }
        cf_last_synced_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }

    Bucket:
      type: object
      properties:
        key: { type: string }
        count: { type: integer }

    DayBucket:
      type: object
      properties:
        day: { type: string, example: "2026-05-28" }
        count: { type: integer }

    HourBucket:
      type: object
      properties:
        hour: { type: integer, minimum: 0, maximum: 23 }
        count: { type: integer }

    WeekdayBucket:
      type: object
      properties:
        name: { type: string, enum: [Sun, Mon, Tue, Wed, Thu, Fri, Sat] }
        count: { type: integer }

    StatsOverview:
      type: object
      properties:
        totals:
          type: object
          properties:
            links: { type: integer }
            clicks: { type: integer }
            unique_clicks: { type: integer }
            unique_referrers: { type: integer }
            countries: { type: integer }
            top_referrer: { oneOf: [{ $ref: "#/components/schemas/Bucket" }, { type: "null" }] }
            top_country: { oneOf: [{ $ref: "#/components/schemas/Bucket" }, { type: "null" }] }
        comparison:
          type: object
          properties:
            prev_clicks: { type: integer }
            trend_pct: { type: integer, description: "Percent change vs the previous window of equal length." }
            clicks_per_link: { type: number }
        link_performance:
          type: object
          properties:
            health: { type: integer, minimum: 0, maximum: 100 }
            active: { type: integer }
            total_links: { type: integer }
            growing: { type: integer }
            declining: { type: integer }
            growing_links:
              type: array
              items:
                type: object
                properties:
                  id: { type: integer }
                  code: { type: string }
                  title: { type: string }
                  clicks: { type: integer }
                  delta_pct: { type: integer }
        by_day: { type: array, items: { $ref: "#/components/schemas/DayBucket" } }
        links_created_by_day: { type: array, items: { $ref: "#/components/schemas/DayBucket" } }
        by_country: { type: array, items: { $ref: "#/components/schemas/Bucket" } }
        by_device: { type: array, items: { $ref: "#/components/schemas/Bucket" } }
        by_browser: { type: array, items: { $ref: "#/components/schemas/Bucket" } }
        by_referrer: { type: array, items: { $ref: "#/components/schemas/Bucket" } }
        by_hour: { type: array, items: { $ref: "#/components/schemas/HourBucket" } }
        by_weekday: { type: array, items: { $ref: "#/components/schemas/WeekdayBucket" } }
        peak_hour: { oneOf: [{ $ref: "#/components/schemas/HourBucket" }, { type: "null" }] }
        peak_weekday: { oneOf: [{ $ref: "#/components/schemas/WeekdayBucket" }, { type: "null" }] }
        top_links:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              code: { type: string }
              title: { type: string }
              clicks: { type: integer }
        recent_clicks:
          type: array
          items: { $ref: "#/components/schemas/ClickRow" }
        window_days: { type: integer }

    LinkStats:
      type: object
      properties:
        link: { $ref: "#/components/schemas/Link" }
        totals:
          type: object
          properties:
            clicks: { type: integer }
            unique_clicks: { type: integer }
            countries: { type: integer }
            top_referrer: { oneOf: [{ $ref: "#/components/schemas/Bucket" }, { type: "null" }] }
            top_country: { oneOf: [{ $ref: "#/components/schemas/Bucket" }, { type: "null" }] }
        by_day: { type: array, items: { $ref: "#/components/schemas/DayBucket" } }
        by_country: { type: array, items: { $ref: "#/components/schemas/Bucket" } }
        by_device: { type: array, items: { $ref: "#/components/schemas/Bucket" } }
        by_browser: { type: array, items: { $ref: "#/components/schemas/Bucket" } }
        by_referrer: { type: array, items: { $ref: "#/components/schemas/Bucket" } }
        by_language: { type: array, items: { $ref: "#/components/schemas/Bucket" } }
        by_weekday: { type: array, items: { $ref: "#/components/schemas/WeekdayBucket" } }
        by_hour: { type: array, items: { $ref: "#/components/schemas/HourBucket" } }
        peak_hour: { oneOf: [{ $ref: "#/components/schemas/HourBucket" }, { type: "null" }] }
        peak_weekday: { oneOf: [{ $ref: "#/components/schemas/WeekdayBucket" }, { type: "null" }] }
        recent_clicks: { type: array, items: { $ref: "#/components/schemas/ClickRow" } }
        window_days: { type: integer }

    ClickRow:
      type: object
      properties:
        link_id: { type: integer }
        link_code: { type: string }
        link_title: { type: string }
        ts: { type: string, format: date-time }
        country: { type: [string, "null"], description: "ISO2 country code from Cloudflare." }
        city: { type: [string, "null"] }
        region: { type: [string, "null"] }
        device: { type: [string, "null"], description: "ios | android | mac | windows | linux | other" }
        browser: { type: [string, "null"], description: "chrome | safari | firefox | edge | opera | other" }
        referrer: { type: [string, "null"] }
        language: { type: [string, "null"] }
        ip: { type: [string, "null"], description: "Only present on per-link stats." }
