openapi: 3.0.3
info:
  title: Linkly API
  version: "1.0.0"
  description: >
    Branch.io-style smart deep linking. Create links, pull click analytics, and
    open/match sessions from the SDKs.


    **Authentication.** Each project has two keys: a secret `apiKey`
    (server-side; full access) and a publishable `sdkKey` (safe in clients; may
    call `POST /api/v1/links` and `POST /api/v1/open`). Pass a key as
    `Authorization: Bearer <key>` or `x-api-key: <key>`. The SDK endpoints also
    accept the key in the JSON body as `key` (CORS-simple) and send CORS headers.
  license:
    name: MIT
servers:
  - url: http://localhost:3000
    description: Local development
  - url: https://your-domain.com
    description: Your deployment

tags:
  - name: Auth
  - name: Links
  - name: Analytics
  - name: Sessions
  - name: Well-known

paths:
  /api/register:
    post:
      tags: [Auth]
      summary: Create a user account
      description: Registers a user and creates a default project.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RegisterInput"
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  email: { type: string, format: email }
        "400": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }

  /api/v1/links:
    post:
      tags: [Links]
      summary: Create a smart link
      description: Accepts the secret `apiKey` or the publishable `sdkKey`.
      security:
        - bearerAuth: []
        - apiKeyHeader: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LinkInput"
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Link" }
        "401": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
    get:
      tags: [Links]
      summary: List links
      description: Newest first. Requires the secret `apiKey`.
      security:
        - bearerAuth: []
        - apiKeyHeader: []
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 50, maximum: 100 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Link" }
        "401": { $ref: "#/components/responses/Error" }

  /api/v1/links/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Links]
      summary: Get a link
      security:
        - bearerAuth: []
        - apiKeyHeader: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Link" }
        "401": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
    delete:
      tags: [Links]
      summary: Delete a link
      security:
        - bearerAuth: []
        - apiKeyHeader: []
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: boolean, example: true }
        "401": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }

  /api/v1/links/{id}/stats:
    get:
      tags: [Analytics]
      summary: Click analytics for a link
      security:
        - bearerAuth: []
        - apiKeyHeader: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: days
          in: query
          schema: { type: integer, default: 30, minimum: 1, maximum: 365 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Stats" }
        "401": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }

  /api/v1/open:
    post:
      tags: [Sessions]
      summary: Open / match a session
      description: >
        Returns a link's attributes for a direct open (a `link` is supplied) or
        performs deferred matching by the request's IP + platform fingerprint
        (within 60 minutes, claimed once). Accepts `apiKey` or `sdkKey`.
      security:
        - bearerAuth: []
        - apiKeyHeader: []
        - {}
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OpenInput" }
      responses:
        "200":
          description: Session (matched or organic)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
        "401": { $ref: "#/components/responses/Error" }
    get:
      tags: [Sessions]
      summary: Open / match a session (query variant)
      parameters:
        - name: key
          in: query
          schema: { type: string }
        - name: platform
          in: query
          schema: { type: string, enum: [ios, android, desktop] }
        - name: link
          in: query
          schema: { type: string }
      responses:
        "200":
          description: Session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }

  /l/{slug}:
    get:
      tags: [Links]
      summary: Redirect engine (not JSON)
      description: >
        Detects the device and either 302-redirects (desktop) or returns an HTML
        interstitial (mobile) that opens the app then falls back to the store.
        Records a click.
      parameters:
        - name: slug
          in: path
          required: true
          schema: { type: string }
      responses:
        "302": { description: Redirect to the resolved destination (desktop) }
        "200": { description: HTML interstitial (mobile) }
        "404": { description: Unknown or inactive link }

  /.well-known/apple-app-site-association:
    get:
      tags: [Well-known]
      summary: Apple App Site Association (iOS Universal Links)
      responses:
        "200":
          description: AASA JSON aggregated from projects' Team ID + bundle ID
          content:
            application/json: {}

  /.well-known/assetlinks.json:
    get:
      tags: [Well-known]
      summary: Google Digital Asset Links (Android App Links)
      responses:
        "200":
          description: assetlinks JSON aggregated from projects' package + SHA-256
          content:
            application/json: {}

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "Authorization: Bearer <apiKey | sdkKey>"
    apiKeyHeader:
      type: apiKey
      in: header
      name: x-api-key
  responses:
    Error:
      description: Error
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
  schemas:
    Error:
      type: object
      properties:
        error: { type: string }
    RegisterInput:
      type: object
      required: [email, password]
      properties:
        name: { type: string }
        email: { type: string, format: email }
        password: { type: string, minLength: 8 }
    LinkInput:
      type: object
      required: [fallbackUrl]
      properties:
        key:
          type: string
          description: API/SDK key (when not sent as a header)
        fallbackUrl: { type: string, format: uri, description: Web/default destination }
        slug:
          type: string
          pattern: "^[a-z0-9-]+$"
          minLength: 3
          maxLength: 40
          description: Optional custom slug; auto-generated if omitted
        title: { type: string }
        iosUrl: { type: string, format: uri }
        androidUrl: { type: string, format: uri }
        desktopUrl: { type: string, format: uri }
        deepLinkPath: { type: string, example: "/products/123" }
        data:
          type: object
          additionalProperties: true
          description: Free-form deep-link attributes
        channel: { type: string }
        campaign: { type: string }
        feature: { type: string }
        tags:
          type: array
          items: { type: string }
    Link:
      type: object
      properties:
        id: { type: string }
        slug: { type: string }
        url: { type: string, format: uri }
        title: { type: string, nullable: true }
        fallbackUrl: { type: string, format: uri }
        iosUrl: { type: string, nullable: true }
        androidUrl: { type: string, nullable: true }
        desktopUrl: { type: string, nullable: true }
        deepLinkPath: { type: string, nullable: true }
        data: { type: object, additionalProperties: true, nullable: true }
        channel: { type: string, nullable: true }
        campaign: { type: string, nullable: true }
        feature: { type: string, nullable: true }
        tags:
          type: array
          items: { type: string }
        active: { type: boolean }
        clicks: { type: integer, description: Present on list/get responses }
    OpenInput:
      type: object
      properties:
        key: { type: string, description: sdkKey or apiKey (or send as a header) }
        platform: { type: string, enum: [ios, android, desktop] }
        link: { type: string, description: Slug or short URL for a direct open }
    Bucket:
      type: object
      properties:
        name: { type: string }
        value: { type: integer }
    Stats:
      type: object
      properties:
        total: { type: integer }
        unique: { type: integer }
        byPlatform: { type: array, items: { $ref: "#/components/schemas/Bucket" } }
        byOs: { type: array, items: { $ref: "#/components/schemas/Bucket" } }
        byReferrer: { type: array, items: { $ref: "#/components/schemas/Bucket" } }
        timeseries:
          type: array
          items:
            type: object
            properties:
              date: { type: string, example: "2026-05-01" }
              clicks: { type: integer }
    Session:
      type: object
      properties:
        matched: { type: boolean }
        is_first_session: { type: boolean }
        is_deferred: { type: boolean }
        link:
          type: object
          properties:
            id: { type: string }
            slug: { type: string }
            url: { type: string, format: uri }
            title: { type: string, nullable: true }
        deepLinkPath: { type: string, nullable: true }
        data:
          type: object
          additionalProperties: true
          description: Link attributes merged with ~channel/~campaign/~feature/~tags
        appUrl: { type: string, nullable: true, description: Native deep link for the platform }
        storeUrl: { type: string, description: App Store / Play Store / web fallback }
        routing:
          type: object
          properties:
            fallbackUrl: { type: string }
            iosUrl: { type: string, nullable: true }
            androidUrl: { type: string, nullable: true }
            desktopUrl: { type: string, nullable: true }
