openapi: 3.1.0

info:
  title: Klickr API
  version: 1.0.0
  summary: Create short links, ephemeral ticket links, and read public platform stats.
  description: |
    Welcome to the Klickr developer reference. Klickr turns one short link into infinite
    destinations: platform-aware redirects, rich social previews, QR codes, click analytics,
    and SequrMark link verification.

    This API lets external systems create links programmatically. Everything else
    (campaigns, analytics, user management, billing) is managed in the
    [Klickr dashboard](https://klickr.io/dashboard).

    ## Base URLs

    | Purpose | Base URL |
    |---|---|
    | API endpoints | `https://api.klickr.io` |
    | Short link resolution | `https://klickr.io` |

    All endpoints live under `/v1`. Breaking changes will ship under a new version prefix,
    never by changing the behaviour of an existing path.

    Older integrations may still call the functions directly at
    `https://us-central1-klickr-io.cloudfunctions.net/createKlickApi`,
    `.../createTicketLinkApi` and `.../getPlatformStats`. Those URLs keep working but are
    tied to a deployment region and are not guaranteed to be stable. Use `api.klickr.io`.

    ## Authentication

    Write endpoints are authenticated with an API key sent in the `x-api-key` header.

    * Keys are created in the dashboard under **Businesses → API Keys**. The raw key is
      shown **once** at creation time and only a SHA-256 hash is stored, so copy it
      immediately.
    * Keys look like `klk_` followed by 64 hex characters.
    * Every key is scoped to a single business. Links you create are owned by that
      business and appear in its dashboard.
    * Keys can be revoked at any time from the same screen. A revoked key returns
      `401 Unauthorized` on the next request.
    * Treat keys as secrets: call the API from your backend, never from a browser or
      mobile app you ship to users.

    Public endpoints (platform stats and link resolution) need no credentials.

    ## Quick start

    ```bash
    curl -X POST "https://api.klickr.io/v1/klicks" \
      -H "Content-Type: application/json" \
      -H "x-api-key: klk_your_api_key" \
      -d '{
        "name": "Spring launch",
        "destinationUrl": "https://example.com/spring",
        "slug": "spring-2026"
      }'
    ```

    ```json
    {
      "id": "spring-2026",
      "shortUrl": "https://klickr.io/spring-2026",
      "destinationUrl": "https://example.com/spring",
      "name": "Spring launch",
      "createdAt": "2026-09-07T09:30:00.000Z"
    }
    ```

    ## Conventions

    * Request and response bodies are JSON (`Content-Type: application/json`).
    * Timestamps are ISO 8601 strings in UTC.
    * Every error response is a JSON object with a single human-readable `error` string.
    * Write endpoints accept `POST` only. Any other method returns `405`.
    * All API endpoints send permissive CORS headers (`Access-Control-Allow-Origin: *`)
      and answer `OPTIONS` preflight requests with `204`.

    ## Choosing between Klicks and Ticket Links

    | | Klick | Ticket Link |
    |---|---|---|
    | Endpoint | `POST /v1/klicks` | `POST /v1/ticket-links` |
    | Lifetime | Permanent until archived | Expires at `expiryDate`, then deleted |
    | Dashboard | Yes: analytics, QR code, editing | No (counter on the business only) |
    | Click analytics | Yes | No |
    | Social previews, platform targeting | Yes | No |
    | SequrMark verification | Yes | No |
    | Batch create | No | Yes, up to 499 per request |
    | Typical use | Marketing links, QR codes, app downloads | One-shot SMS ticket URLs, OTP-style links |

    ## Limits

    * `createTicketLinkApi` accepts at most **499** items per request.
    * The redirect host rate-limits clients that request more than 5 unknown short codes
      per minute from one IP (`429`).
    * There is no published request quota on the API endpoints. Please keep sustained
      traffic reasonable and contact Klickr before running large migrations.

    ## What is not in this reference

    The Klickr dashboard and partner applications also use Firebase callable functions
    (user invitations, click recording, SequrMark vetting and token management). Those
    require a Firebase ID token from the Klickr app and are not part of the public API.
  contact:
    name: Klickr
    url: https://klickr.io
  license:
    name: Proprietary
    identifier: LicenseRef-Klickr-Proprietary

servers:
  - url: https://api.klickr.io
    description: Production API

security:
  - ApiKeyAuth: []

tags:
  - name: Klicks
    description: |
      Permanent short links managed in the Klickr dashboard. A Klick carries a display
      name, an optional custom slug, platform-specific destinations, Open Graph social
      preview metadata, and click analytics.
  - name: Ticket Links
    description: |
      Ephemeral short links for transactional messages such as SMS tickets. They resolve
      with a fast `302` redirect, carry no analytics or social preview, and are deleted
      automatically once their expiry passes.
  - name: Platform
    description: Public, unauthenticated read endpoints.
  - name: Resolution
    description: |
      How `klickr.io/{shortCode}` behaves when a person or a crawler opens a link. This is
      served from `https://klickr.io`, not from the API base URL.

paths:
  /v1/klicks:
    post:
      tags: [Klicks]
      operationId: createKlick
      summary: Create a Klick
      description: |
        Creates a permanent short link owned by the business the API key belongs to.

        * Omit `slug` to receive a random, URL-safe short code.
        * Slugs are unique case-insensitively across all Klicks and Ticket Links.
          `Summer-Sale` and `summer-sale` are the same link.
        * `campaignId` must reference a campaign that belongs to the same business.
        * Set `isConfirmation: true` for transactional links (order or ticket confirmations).
          They are listed under the **Confirmations** tab in the dashboard rather than with
          marketing links, but otherwise behave like any Klick.

        The link is live immediately at `https://klickr.io/{id}`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateKlickRequest'
            examples:
              minimal:
                summary: Minimal (random short code)
                value:
                  name: My campaign link
                  destinationUrl: https://example.com/landing-page
              customSlug:
                summary: Custom slug
                value:
                  name: Summer sale 2026
                  destinationUrl: https://example.com/summer-sale
                  slug: summer-sale-2026
              fullOptions:
                summary: Platform targeting and social preview
                value:
                  name: App download link
                  destinationUrl: https://example.com/download
                  slug: get-app
                  campaignId: 3f9Kq2LmPzRt
                  platformDestinations:
                    ios: https://apps.apple.com/app/example/id123456
                    android: https://play.google.com/store/apps/details?id=com.example
                    other: https://example.com/download
                  socialPreview:
                    title: Download our app
                    description: Available on iOS and Android.
                    imageUrl: https://example.com/og/app.png
              confirmation:
                summary: Confirmation link
                value:
                  name: 'Ticket #TK-48291'
                  destinationUrl: https://tickets.example.com/view/TK-48291
                  isConfirmation: true
      responses:
        '201':
          description: Klick created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/KlickCreated'
              examples:
                randomCode:
                  value:
                    id: aB3xKz9mNwPq
                    shortUrl: https://klickr.io/aB3xKz9mNwPq
                    destinationUrl: https://example.com/landing-page
                    name: My campaign link
                    createdAt: '2026-09-07T09:30:00.000Z'
                customSlug:
                  value:
                    id: summer-sale-2026
                    shortUrl: https://klickr.io/summer-sale-2026
                    destinationUrl: https://example.com/summer-sale
                    name: Summer sale 2026
                    createdAt: '2026-09-07T09:30:00.000Z'
        '400':
          description: Validation failed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                missingFields:
                  value: { error: 'Missing required fields: destinationUrl, name' }
                badUrl:
                  value: { error: 'Invalid destinationUrl — must be a valid http(s) URL' }
                badName:
                  value: { error: 'name must be a non-empty string (max 200 chars)' }
                badSlug:
                  value: { error: 'Invalid slug — must be 3-64 characters, alphanumeric, hyphens, or underscores' }
                unknownPlatform:
                  value: { error: 'Unknown platform: windows' }
                badPlatformUrl:
                  value: { error: 'Invalid URL for platform ios' }
                badCampaign:
                  value: { error: 'Invalid campaignId — not found or not owned by this business' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
        '409':
          description: The slug is already taken by another Klick or Ticket Link.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example: { error: This slug is already taken }
        '500':
          $ref: '#/components/responses/InternalError'

  /v1/ticket-links:
    post:
      tags: [Ticket Links]
      operationId: createTicketLinks
      summary: Create one or many Ticket Links
      description: |
        Creates ephemeral short links. Send a single object to create one link, or an
        array (max 499) to create many in one atomic write. If any item fails validation
        or collides with an existing slug, nothing is created and the error names the
        offending item by its zero-based index.

        Behaviour of a Ticket Link:

        * `slug` is required and is lower-cased. The returned `shortUrl` has no scheme
          (`klickr.io/abc123`) to save characters in SMS messages. Prefix `https://` if
          your channel needs it.
        * `expiryDate` must be in the future. After that instant the link resolves to
          `404`, and the record is deleted by a background TTL job (up to 24 hours later).
        * Resolution is a plain `302` to `destinationUrl`. No click analytics, social
          preview, platform targeting, or verification is applied.
        * Ticket Links do not appear in the dashboard. The business record keeps a
          lifetime `ticketLinkCount`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: '#/components/schemas/CreateTicketLinkRequest'
                - type: array
                  title: Batch
                  description: Up to 499 ticket links, created atomically.
                  minItems: 1
                  maxItems: 499
                  items:
                    $ref: '#/components/schemas/CreateTicketLinkRequest'
            examples:
              single:
                summary: Single link
                value:
                  slug: TK48291
                  destinationUrl: https://tickets.example.com/view/TK-48291
                  expiryDate: '2026-12-31T23:59:59Z'
              batch:
                summary: Batch of links
                value:
                  - slug: TK48291
                    destinationUrl: https://tickets.example.com/view/TK-48291
                    expiryDate: '2026-12-31T23:59:59Z'
                  - slug: TK48292
                    destinationUrl: https://tickets.example.com/view/TK-48292
                    expiryDate: '2026-12-31T23:59:59Z'
      responses:
        '201':
          description: |
            Created. The shape depends on the request: a single object returns one
            `TicketLink`, an array returns a `TicketLinkBatch`.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/TicketLink'
                  - $ref: '#/components/schemas/TicketLinkBatch'
              examples:
                single:
                  summary: Single link
                  value:
                    slug: tk48291
                    shortUrl: klickr.io/tk48291
                    destinationUrl: https://tickets.example.com/view/TK-48291
                    expiresAt: '2026-12-31T23:59:59.000Z'
                batch:
                  summary: Batch of links
                  value:
                    created: 2
                    links:
                      - slug: tk48291
                        shortUrl: klickr.io/tk48291
                        destinationUrl: https://tickets.example.com/view/TK-48291
                        expiresAt: '2026-12-31T23:59:59.000Z'
                      - slug: tk48292
                        shortUrl: klickr.io/tk48292
                        destinationUrl: https://tickets.example.com/view/TK-48292
                        expiresAt: '2026-12-31T23:59:59.000Z'
        '400':
          description: |
            Validation failed. Item errors are prefixed with the zero-based index of the
            failing item, for example `Item 3: invalid destinationUrl`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                empty:
                  value: { error: No items provided }
                tooMany:
                  value: { error: Maximum 499 items per request }
                missingFields:
                  value: { error: 'Item 0: missing required fields (destinationUrl, slug, expiryDate)' }
                badUrl:
                  value: { error: 'Item 0: invalid destinationUrl' }
                badSlug:
                  value: { error: 'Item 0: invalid slug — must be 3-64 chars, alphanumeric, hyphens, or underscores' }
                badDate:
                  value: { error: 'Item 0: invalid expiryDate (must be ISO 8601)' }
                pastDate:
                  value: { error: 'Item 0: expiryDate must be in the future' }
                duplicateInRequest:
                  value: { error: 'Duplicate slug in request: TK48291' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
        '409':
          description: A slug collides with an existing Klick or Ticket Link. Nothing was created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example: { error: 'Slug already taken: tk48291' }
        '500':
          $ref: '#/components/responses/InternalError'

  /v1/stats:
    get:
      tags: [Platform]
      operationId: getPlatformStats
      summary: Get platform statistics
      security: []
      description: |
        Aggregate, platform-wide counters as shown on the Klickr landing page. The
        response is cached at the CDN for five minutes (`Cache-Control: public, max-age=300`)
        and served from a periodically refreshed snapshot, so `updatedAt` may lag real time.
      responses:
        '200':
          description: Current platform statistics.
          headers:
            Cache-Control:
              schema:
                type: string
              example: public, max-age=300
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlatformStats'
              example:
                totalLinks: 34
                totalClicks: 30948
                totalBusinesses: 8
                updatedAt: '2026-03-14T18:51:07.703Z'
        '500':
          description: Stats could not be computed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example: { error: Failed to get platform stats }

  /{shortCode}:
    servers:
      - url: https://klickr.io
        description: Short link host
    get:
      tags: [Resolution]
      operationId: resolveShortCode
      summary: Resolve a short link
      security: []
      description: |
        Opens a Klick or Ticket Link. Matching is case-insensitive. This endpoint is
        designed for browsers and crawlers rather than API clients, so most outcomes are
        HTML pages. Use it to understand what your users will experience.

        **Klicks**

        | Situation | Status | Body |
        |---|---|---|
        | Browser opens an active link | `200` | HTML page that immediately redirects to the destination with JavaScript |
        | Social crawler or bot opens an active link | `200` | HTML with Open Graph and Twitter Card meta tags, no redirect |
        | Link is SequrMark-verified | `200` | Verified interstitial page showing the trust score before continuing |
        | Link failed verification or its token was revoked | `200` | Warning page; the visitor may still choose to continue |
        | Link is archived | `410` | HTML that redirects to `https://klickr.io/removed` |
        | Unknown short code | `404` | Not-found page |

        Platform targeting is applied from the `User-Agent`: iOS devices go to
        `platformDestinations.ios`, Android to `platformDestinations.android`, everything
        else to `platformDestinations.other`, falling back to `destinationUrl`. Clicks from
        non-bot user agents are counted in analytics.

        **Ticket Links**

        An unexpired Ticket Link answers with a plain `302` and a `Location` header. An
        expired one answers `404`.

        **Abuse protection**

        Known scanner IP ranges receive `403`. Short codes that look like vulnerability
        probes (for example `wp-login.php` or `.env`) receive `404` without a lookup. An
        IP that requests more than 5 unknown short codes within one minute receives `429`.
      parameters:
        - name: shortCode
          in: path
          required: true
          description: The Klick slug or Ticket Link slug. Case-insensitive.
          schema:
            type: string
            pattern: '^[a-zA-Z0-9_-]{3,64}$'
            example: summer-sale-2026
        - name: User-Agent
          in: header
          required: false
          description: Drives platform targeting and bot detection.
          schema:
            type: string
      responses:
        '200':
          description: |
            Active Klick. The HTML body is one of: a JavaScript redirect (browsers), an
            Open Graph preview (crawlers), a verified interstitial, or a warning page.
          headers:
            Cache-Control:
              schema:
                type: string
              example: private, max-age=0, no-cache, must-revalidate
          content:
            text/html:
              schema:
                type: string
        '302':
          description: Unexpired Ticket Link. Follow `Location`.
          headers:
            Location:
              description: The Ticket Link destination URL.
              schema:
                type: string
                format: uri
        '403':
          description: Request came from a blocked IP.
          content:
            text/plain:
              schema:
                type: string
                example: Forbidden
        '404':
          description: Unknown or expired short code, or a blocked scanner-style path.
          content:
            text/html:
              schema:
                type: string
        '410':
          description: Archived Klick. The HTML body redirects to `https://klickr.io/removed`.
          content:
            text/html:
              schema:
                type: string
        '429':
          description: Too many unknown short codes requested from this IP in the last minute.
          content:
            text/plain:
              schema:
                type: string
                example: Too many requests
        '500':
          description: Unexpected error while resolving the link.
          content:
            text/plain:
              schema:
                type: string

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: x-api-key
      description: |
        Business-scoped API key created in the Klickr dashboard (**Businesses → API Keys**).
        Format: `klk_` followed by 64 hexadecimal characters.

  responses:
    Unauthorized:
      description: The `x-api-key` header is missing, malformed, revoked, or unknown.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            missing:
              value: { error: Missing or invalid API key }
            unknown:
              value: { error: Invalid API key }
    MethodNotAllowed:
      description: Only `POST` (and `OPTIONS` preflight) is accepted.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example: { error: Method not allowed }
    InternalError:
      description: Unexpected server error. Safe to retry with backoff.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example: { error: Internal error }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Human-readable description of what went wrong.
      example: { error: Invalid API key }

    Slug:
      type: string
      description: |
        Custom short code. 3 to 64 characters from `A-Z a-z 0-9 _ -`. Uniqueness is
        checked case-insensitively across Klicks and Ticket Links.
      pattern: '^[a-zA-Z0-9_-]{3,64}$'
      minLength: 3
      maxLength: 64
      examples: [summer-sale-2026, TK48291]

    HttpUrl:
      type: string
      format: uri
      description: Absolute `http://` or `https://` URL.
      examples: [https://example.com/landing-page]

    PlatformDestinations:
      type: object
      description: |
        Optional per-platform destinations chosen from the visitor's `User-Agent`. Any
        platform left out falls back to `destinationUrl`. Only the three keys below are
        accepted; any other key is rejected with `400`.
      properties:
        ios:
          allOf: [{ $ref: '#/components/schemas/HttpUrl' }]
          description: Destination for iPhone, iPad, and iPod visitors.
        android:
          allOf: [{ $ref: '#/components/schemas/HttpUrl' }]
          description: Destination for Android visitors.
        other:
          allOf: [{ $ref: '#/components/schemas/HttpUrl' }]
          description: Destination for every other visitor, including desktop browsers.
      additionalProperties: false

    SocialPreview:
      type: object
      description: |
        Open Graph and Twitter Card metadata served to social crawlers. Missing fields fall
        back to the Klick name, a default description, and the Klickr default image.
      properties:
        title:
          type: string
          description: '`og:title`. Defaults to the Klick name.'
        description:
          type: string
          description: '`og:description`.'
        imageUrl:
          allOf: [{ $ref: '#/components/schemas/HttpUrl' }]
          description: '`og:image`. A 1200×630 image works best across networks.'

    CreateKlickRequest:
      type: object
      required: [name, destinationUrl]
      properties:
        name:
          type: string
          description: Display name shown in the dashboard. Trimmed; 1 to 200 characters.
          minLength: 1
          maxLength: 200
          examples: [Summer sale 2026]
        destinationUrl:
          allOf: [{ $ref: '#/components/schemas/HttpUrl' }]
          description: Default destination when no platform-specific URL applies.
        slug:
          allOf: [{ $ref: '#/components/schemas/Slug' }]
          description: Custom short code. Omit for a random, URL-safe code.
        campaignId:
          type: string
          description: ID of a campaign belonging to the same business as the API key.
          examples: [3f9Kq2LmPzRt]
        platformDestinations:
          $ref: '#/components/schemas/PlatformDestinations'
        socialPreview:
          $ref: '#/components/schemas/SocialPreview'
        isConfirmation:
          type: boolean
          default: false
          description: |
            Mark as a transactional confirmation link. Listed under the **Confirmations**
            tab in the dashboard instead of with marketing links.

    KlickCreated:
      type: object
      required: [id, shortUrl, destinationUrl, name, createdAt]
      properties:
        id:
          type: string
          description: The Klick ID. Equals `slug` when one was supplied, otherwise a random code.
          examples: [summer-sale-2026, aB3xKz9mNwPq]
        shortUrl:
          type: string
          format: uri
          description: The public short link.
          examples: [https://klickr.io/summer-sale-2026]
        destinationUrl:
          $ref: '#/components/schemas/HttpUrl'
        name:
          type: string
          description: The trimmed display name.
        createdAt:
          type: string
          format: date-time
          description: Creation time in UTC.

    CreateTicketLinkRequest:
      type: object
      title: TicketLinkInput
      required: [slug, destinationUrl, expiryDate]
      properties:
        slug:
          allOf: [{ $ref: '#/components/schemas/Slug' }]
          description: Required. Stored and returned in lower case.
        destinationUrl:
          allOf: [{ $ref: '#/components/schemas/HttpUrl' }]
          description: Where the link redirects.
        expiryDate:
          type: string
          format: date-time
          description: ISO 8601 timestamp after which the link stops resolving. Must be in the future.
          examples: ['2026-12-31T23:59:59Z']

    TicketLink:
      type: object
      required: [slug, shortUrl, destinationUrl, expiresAt]
      properties:
        slug:
          type: string
          description: The lower-cased slug, which is also the document ID.
          examples: [tk48291]
        shortUrl:
          type: string
          description: Public short link **without** a scheme, ready to drop into an SMS.
          examples: [klickr.io/tk48291]
        destinationUrl:
          $ref: '#/components/schemas/HttpUrl'
        expiresAt:
          type: string
          format: date-time
          description: Normalised expiry in UTC.

    TicketLinkBatch:
      type: object
      required: [created, links]
      properties:
        created:
          type: integer
          description: Number of links created. Equals the request array length.
          examples: [2]
        links:
          type: array
          items:
            $ref: '#/components/schemas/TicketLink'

    PlatformStats:
      type: object
      required: [totalLinks, totalClicks, totalBusinesses, updatedAt]
      properties:
        totalLinks:
          type: integer
          description: Total Klicks ever created on the platform.
        totalClicks:
          type: integer
          description: Sum of click counts across all Klicks.
        totalBusinesses:
          type: integer
          description: Number of businesses on the platform.
        updatedAt:
          type: string
          format: date-time
          description: When the snapshot was last recomputed.
