openapi: 3.0.3
info:
  title: Linkly API
  version: "1.0.0"
  description: >
    Smart deep linking & attribution. 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: Proprietary
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: Events
  - 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/bulk:
    post:
      tags: [Links]
      summary: Bulk-create links
      description: Create up to 500 links in one call. Requires the secret `apiKey`. Returns per-row results.
      security:
        - bearerAuth: []
        - apiKeyHeader: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [links]
              properties:
                links:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items: { $ref: "#/components/schemas/LinkInput" }
      responses:
        "201":
          description: Created (possibly partial)
          content:
            application/json:
              schema:
                type: object
                properties:
                  created: { type: integer }
                  failed: { type: integer }
                  results:
                    type: array
                    items:
                      type: object
                      properties:
                        ok: { type: boolean }
                        slug: { type: string }
                        url: { type: string, format: uri }
                        error: { type: string }
        "401": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }

  /api/v1/links/{id}/export:
    get:
      tags: [Analytics]
      summary: Export raw click rows
      description: Exports non-bot clicks as CSV (default) or JSON. Requires the secret `apiKey`.
      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 }
        - name: format
          in: query
          schema: { type: string, enum: [csv, json], default: csv }
      responses:
        "200":
          description: Click rows
          content:
            text/csv: {}
            application/json: {}
        "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" }

  /api/v1/events:
    post:
      tags: [Events]
      summary: Record a post-install / in-app event
      description: >
        Records a behavioral event for funnels and conversion attribution.
        Accepts `sdkKey` or `apiKey`. Attribution (channel/campaign) comes from
        `link` if supplied, otherwise inherited from the identity's install event.
      security:
        - bearerAuth: []
        - apiKeyHeader: []
        - {}
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EventInput" }
      responses:
        "201":
          description: Recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  event: { $ref: "#/components/schemas/Event" }
        "401": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
    get:
      tags: [Events]
      summary: List recent events
      description: Requires the secret `apiKey`.
      security:
        - bearerAuth: []
        - apiKeyHeader: []
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 50, maximum: 200 }
        - name: name
          in: query
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Event" }
        "401": { $ref: "#/components/responses/Error" }

  /api/v1/funnels/{id}:
    get:
      tags: [Analytics]
      summary: Funnel conversion stats
      description: Step-by-step conversion for a funnel, optionally segmented by channel/campaign. Requires the secret `apiKey`.
      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 }
        - name: channel
          in: query
          schema: { type: string }
        - name: campaign
          in: query
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Funnel" }
        "401": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }

  /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, no pixels) }
        "200": { description: "HTML interstitial (mobile), password gate, or pixel/cloaked redirect page" }
        "401": { description: Password-protected link, wrong password supplied }
        "404": { description: Unknown link, inactive, or not yet active }
        "410": { description: Link has expired }

  /.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 }
        folder: { type: string, description: Organizational folder/group }
        geoRules:
          type: object
          additionalProperties: { type: string, format: uri }
          description: "Per-country web destination, keyed by ISO alpha-2 (e.g. { \"US\": \"https://...\" })"
        rotatorUrls:
          type: array
          items: { type: string, format: uri }
          description: A/B rotator — a random URL is chosen per click
        utmSource: { type: string }
        utmMedium: { type: string }
        utmCampaign: { type: string }
        utmTerm: { type: string }
        utmContent: { type: string, description: UTM params appended to web destinations }
        ogTitle: { type: string }
        ogDescription: { type: string }
        ogImage: { type: string, format: uri, description: Social/OG preview overrides }
        password: { type: string, description: If set, the link is gated behind a password }
        cloaked: { type: boolean, description: Always serve an HTML page that hides the destination }
        activeFrom: { type: string, format: date-time, description: Link is inactive before this time }
        expiresAt: { type: string, format: date-time, description: Link returns 410 after this time }
    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 }
        folder: { type: string, nullable: true }
        geoRules: { type: object, additionalProperties: true, nullable: true }
        rotatorUrls: { type: array, items: { type: string } }
        utmSource: { type: string, nullable: true }
        utmMedium: { type: string, nullable: true }
        utmCampaign: { type: string, nullable: true }
        utmTerm: { type: string, nullable: true }
        utmContent: { type: string, nullable: true }
        ogTitle: { type: string, nullable: true }
        ogDescription: { type: string, nullable: true }
        ogImage: { type: string, nullable: true }
        cloaked: { type: boolean }
        hasPassword: { type: boolean, description: Whether a password is set (the hash is never returned) }
        activeFrom: { type: string, format: date-time, nullable: true }
        expiresAt: { type: string, format: date-time, nullable: true }
        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 }
        identity: { type: string, description: Pseudonymous device/user id; seeds funnel events }
    EventInput:
      type: object
      required: [name]
      properties:
        key: { type: string, description: sdkKey or apiKey (or send as a header) }
        name: { type: string, maxLength: 80, example: purchase }
        identity: { type: string, description: Stable pseudonymous id for funnel stitching }
        value: { type: number, description: Optional revenue/value }
        data: { type: object, additionalProperties: true }
        link: { type: string, description: Slug for first-touch attribution (install event) }
        platform: { type: string, enum: [ios, android, desktop] }
    Event:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        identity: { type: string, nullable: true }
        value: { type: number, nullable: true }
        platform: { type: string, nullable: true }
        channel: { type: string, nullable: true }
        campaign: { type: string, nullable: true }
        feature: { type: string, nullable: true }
        data: { type: object, additionalProperties: true, nullable: true }
        createdAt: { type: string, format: date-time }
    Funnel:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        steps: { type: array, items: { type: string } }
        windowDays: { type: integer }
        totalIdentities: { type: integer, description: Identities that entered the funnel }
        conversionRate: { type: number, description: First-to-last step rate (0..1) }
        steps_detail:
          type: array
          items:
            type: object
            properties:
              step: { type: string }
              count: { type: integer }
              rate: { type: number, description: Conversion from first step }
              stepRate: { type: number, description: Conversion from previous step }
    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" } }
        byCountry: { 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 }
