openapi: 3.1.0
info:
  title: Shortyy Public REST API
  version: 1.0.0
  description: |
    Shortyy is a developer-first URL shortening and redirect management platform engineered for microsecond latency, cryptographic API key security, and seamless multi-language developer integration.

    ### Architectural Invariants:
    1. **Link Immutability**: Once created, short links cannot be edited or updated (`PATCH` is deliberately not supported). To change a target, delete the link and create a new one.
    2. **Permanent Tombstones**: Deleted short codes are permanently archived in the reserved registry (`reserved_codes`) to ensure a deleted code can never be hijacked or recycled.
    3. **Token-Bucket Rate Limiting**: Free-tier API keys operate on continuous token-bucket replenishment (4-token burst capacity, 1 token refilled every 75 seconds).
    4. **Plan-Aware Expiration**: Free-tier API keys have an expiration ceiling of 3 hours (10,800 seconds). Paid tiers (Basic and Pro) support up to 100 years.
  contact:
    name: Shortyy Developer Platform
    url: https://shortyy.com/developers
  license:
    name: MIT
    url: https://opensource.org/licenses/MIT

servers:
  - url: https://api.shortyy.com/v1
    description: Production API Server
  - url: http://localhost:4000/v1
    description: Local Development Server

security:
  - ApiKeyAuth: []
  - HeaderApiKey: []

paths:
  /links:
    post:
      summary: Create Short Link
      description: |
        Generates a new shortened link with optional expiration, custom alias, click limits, and UTM tracking parameters.
        
        Requires `links:create` or `links:write` scope.
      operationId: createLink
      security:
        - ApiKeyAuth:
            - links:create
            - links:write
        - HeaderApiKey:
            - links:create
            - links:write
        - SessionAuth: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          description: Unique client-generated key (UUID or event ID) to safely retry link creation without creating duplicates.
          schema:
            type: string
            example: "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateLinkRequest'
            examples:
              standardLink:
                summary: Standard Short Link
                value:
                  url: "https://stripe.com/docs/api"
                  expires_in: "2h"
              advancedLink:
                summary: Link with Tracking and Quotas
                value:
                  destination_url: "https://example.com/summer-campaign"
                  custom_alias: "summer-launch"
                  requested_expiry: "2026-12-31T23:59:59.000Z"
                  max_clicks: 1000
                  utm_params:
                    utm_source: "newsletter"
                    utm_medium: "email"
                    utm_campaign: "summer_sale"
      responses:
        '201':
          description: Link created successfully.
          headers:
            X-RateLimit-Limit:
              schema:
                type: integer
              description: Maximum token capacity of your rate limit bucket.
            X-RateLimit-Remaining:
              schema:
                type: integer
              description: Available tokens remaining.
            X-RateLimit-Reset:
              schema:
                type: string
                format: date-time
              description: ISO timestamp when the next token will be refilled.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreateLinkResponse'
              example:
                success: true
                data:
                  id: "550e8400-e29b-41d4-a716-446655440000"
                  short_code: "summer-launch"
                  short_url: "https://shortyy.com/summer-launch"
                  destination_url: "https://example.com/summer-campaign"
                  creation_channel: "sdk"
                  status: "active"
                  expires_at: "2026-12-31T23:59:59.000Z"
                  max_clicks: 1000
                  accepted_clicks: 0
                  utm_params:
                    utm_source: "newsletter"
                    utm_medium: "email"
                    utm_campaign: "summer_sale"
                  created_at: "2026-11-13T12:00:00.000Z"
        '400':
          $ref: '#/components/responses/400ValidationError'
        '401':
          $ref: '#/components/responses/401Unauthorized'
        '403':
          $ref: '#/components/responses/403Forbidden'
        '429':
          $ref: '#/components/responses/429RateLimited'
        '500':
          $ref: '#/components/responses/500InternalError'

    get:
      summary: List Links
      description: |
        Retrieves a paginated list of shortened links belonging to the authenticated account.
        
        Requires `links:read` scope.
      operationId: listLinks
      security:
        - ApiKeyAuth:
            - links:read
        - HeaderApiKey:
            - links:read
        - SessionAuth: []
      parameters:
        - name: page
          in: query
          required: false
          description: Page number for pagination (1-indexed).
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: limit
          in: query
          required: false
          description: Number of items per page (maximum 100).
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: status
          in: query
          required: false
          description: Filter links by lifecycle status.
          schema:
            type: string
            enum: [all, active, expired, paused]
            default: all
        - name: search
          in: query
          required: false
          description: Substring match against destination URL or short code.
          schema:
            type: string
      responses:
        '200':
          description: Paginated list of links.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ListLinksResponse'
              example:
                success: true
                data:
                  - id: "550e8400-e29b-41d4-a716-446655440000"
                    short_code: "summer-launch"
                    short_url: "https://shortyy.com/summer-launch"
                    destination_url: "https://example.com/summer-campaign"
                    creation_channel: "sdk"
                    status: "active"
                    expires_at: "2026-12-31T23:59:59.000Z"
                    max_clicks: 1000
                    accepted_clicks: 42
                    utm_params: {}
                    created_at: "2026-11-13T12:00:00.000Z"
                pagination:
                  page: 1
                  limit: 20
                  total: 1
                  totalPages: 1
        '401':
          $ref: '#/components/responses/401Unauthorized'
        '403':
          $ref: '#/components/responses/403Forbidden'
        '500':
          $ref: '#/components/responses/500InternalError'

  /links/{id}:
    get:
      summary: Get Link Details
      description: |
        Retrieves complete metadata and status counters for a specific link by its UUID.
        
        Requires `links:read` scope.
      operationId: getLink
      security:
        - ApiKeyAuth:
            - links:read
        - HeaderApiKey:
            - links:read
        - SessionAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Unique UUID identifier of the link.
          schema:
            type: string
            format: uuid
            example: "550e8400-e29b-41d4-a716-446655440000"
      responses:
        '200':
          description: Link details retrieved successfully.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GetLinkResponse'
              example:
                success: true
                data:
                  id: "550e8400-e29b-41d4-a716-446655440000"
                  short_code: "summer-launch"
                  short_url: "https://shortyy.com/summer-launch"
                  destination_url: "https://example.com/summer-campaign"
                  creation_channel: "sdk"
                  status: "active"
                  expires_at: "2026-12-31T23:59:59.000Z"
                  max_clicks: 1000
                  accepted_clicks: 42
                  utm_params: {}
                  created_at: "2026-11-13T12:00:00.000Z"
        '401':
          $ref: '#/components/responses/401Unauthorized'
        '403':
          $ref: '#/components/responses/403Forbidden'
        '404':
          $ref: '#/components/responses/404NotFound'
        '500':
          $ref: '#/components/responses/500InternalError'

    delete:
      summary: Delete Link
      description: |
        Permanently archives a link, purges it from the Redis redirect cache, and reserves its short code forever in `reserved_codes` (tombstone).
        
        Subsequent requests to the short URL return HTTP 410 Gone.
        Subsequent `DELETE` calls for the same ID return HTTP 404 Not Found.
        
        Requires `links:delete` scope.
      operationId: deleteLink
      security:
        - ApiKeyAuth:
            - links:delete
        - HeaderApiKey:
            - links:delete
        - SessionAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Unique UUID identifier of the link to delete.
          schema:
            type: string
            format: uuid
            example: "550e8400-e29b-41d4-a716-446655440000"
      responses:
        '200':
          description: Link permanently deleted and short code tombstoned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
              example:
                success: true
                message: "Link deleted successfully"
        '401':
          $ref: '#/components/responses/401Unauthorized'
        '403':
          $ref: '#/components/responses/403Forbidden'
        '404':
          $ref: '#/components/responses/404NotFound'
        '500':
          $ref: '#/components/responses/500InternalError'

  /links/{id}/analytics:
    get:
      summary: Get Link Analytics
      description: |
        Retrieves click analytics, remaining click quota, and last activity timestamp for a single link.
        
        Requires `analytics:read` or `links:read` scope.
      operationId: getLinkAnalytics
      security:
        - ApiKeyAuth:
            - analytics:read
            - links:read
        - HeaderApiKey:
            - analytics:read
            - links:read
        - SessionAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Unique UUID identifier of the link.
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Link click metrics retrieved successfully.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LinkAnalyticsResponse'
              example:
                success: true
                data:
                  link_id: "550e8400-e29b-41d4-a716-446655440000"
                  short_code: "summer-launch"
                  destination_url: "https://example.com/summer-campaign"
                  status: "active"
                  accepted_clicks: 42
                  max_clicks: 1000
                  clicks_remaining: 958
                  last_clicked_at: "2026-11-13T12:05:00.000Z"
                  expires_at: "2026-12-31T23:59:59.000Z"
                  utm_params: {}
                  created_at: "2026-11-13T12:00:00.000Z"
                  breakdowns:
                    countries:
                      status: "coming_soon"
                    devices:
                      status: "coming_soon"
                    referrers:
                      status: "coming_soon"
        '401':
          $ref: '#/components/responses/401Unauthorized'
        '403':
          $ref: '#/components/responses/403Forbidden'
        '404':
          $ref: '#/components/responses/404NotFound'
        '500':
          $ref: '#/components/responses/500InternalError'

  /analytics/overview:
    get:
      summary: Get Aggregate Overview Analytics
      description: |
        Retrieves user-wide aggregate link counts and click metrics.
        
        Requires `analytics:read` or `links:read` scope.
      operationId: getAnalyticsOverview
      security:
        - ApiKeyAuth:
            - analytics:read
            - links:read
        - HeaderApiKey:
            - analytics:read
            - links:read
        - SessionAuth: []
      responses:
        '200':
          description: User aggregate metrics retrieved successfully.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnalyticsOverviewResponse'
              example:
                success: true
                data:
                  metrics:
                    total_links: 15
                    active_links: 12
                    total_accepted_clicks: 1420
                  top_links:
                    - id: "550e8400-e29b-41d4-a716-446655440000"
                      short_code: "summer-launch"
                      accepted_clicks: 420
                      last_clicked_at: "2026-11-13T12:05:00.000Z"
                      created_at: "2026-11-13T12:00:00.000Z"
                  breakdowns:
                    countries:
                      status: "coming_soon"
                    devices:
                      status: "coming_soon"
                    referrers:
                      status: "coming_soon"
        '401':
          $ref: '#/components/responses/401Unauthorized'
        '403':
          $ref: '#/components/responses/403Forbidden'
        '500':
          $ref: '#/components/responses/500InternalError'

  /me:
    get:
      summary: Get Account Profile
      description: Retrieves the profile details for the authenticated user account.
      operationId: getMe
      security:
        - ApiKeyAuth: []
        - HeaderApiKey: []
        - SessionAuth: []
      responses:
        '200':
          description: Account profile retrieved successfully.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccountProfileResponse'
        '401':
          $ref: '#/components/responses/401Unauthorized'

  /me/plan:
    get:
      summary: Get Subscription Plan & Entitlements
      description: Retrieves effective subscription plan, rate limits, and feature entitlement flags.
      operationId: getPlan
      security:
        - ApiKeyAuth: []
        - HeaderApiKey: []
        - SessionAuth: []
      responses:
        '200':
          description: Plan capabilities and limits retrieved successfully.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlanEntitlementsResponse'
        '401':
          $ref: '#/components/responses/401Unauthorized'

  /me/usage:
    get:
      summary: Get Usage & Quotas
      description: Retrieves current link count, active links count, and channel rate limit window.
      operationId: getUsage
      security:
        - ApiKeyAuth: []
        - HeaderApiKey: []
        - SessionAuth: []
      responses:
        '200':
          description: Usage statistics retrieved successfully.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UsageMetricsResponse'
        '401':
          $ref: '#/components/responses/401Unauthorized'

components:
  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      bearerFormat: "sh_live_..."
      description: |
        Production API key generated in the Shortyy developer dashboard.
        Format: `Authorization: Bearer sh_live_<entropy>`
    HeaderApiKey:
      type: apiKey
      in: header
      name: X-API-Key
      description: |
        Alternative API key header.
        Format: `X-API-Key: sh_live_<entropy>`
    SessionAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: Authenticated Supabase session JWT used by the web dashboard.

  schemas:
    CreateLinkRequest:
      type: object
      required:
        - url
      properties:
        url:
          type: string
          format: uri
          description: Target destination URL (http:// or https://, up to 2048 characters).
          example: "https://stripe.com/docs/api"
        destination_url:
          type: string
          format: uri
          description: Alias for `url`.
          example: "https://stripe.com/docs/api"
        expires_in:
          type: string
          description: |
            Relative expiration duration.
            Examples: "30m", "1h", "2h", "1d".
            Note: On Free plan API keys, maximum lifetime is 3 hours (10,800s).
          example: "2h"
        expiresIn:
          type: string
          description: CamelCase alias for `expires_in`.
          example: "2h"
        requested_expiry:
          type: string
          format: date-time
          description: Absolute ISO-8601 future timestamp.
          example: "2026-12-31T23:59:59.000Z"
        custom_alias:
          type: string
          description: Optional custom short code (requires Basic or Pro plan).
          minLength: 3
          maxLength: 64
          pattern: "^[a-zA-Z0-9_-]+$"
          example: "summer-launch"
        customAlias:
          type: string
          description: CamelCase alias for `custom_alias`.
          example: "summer-launch"
        max_clicks:
          type: integer
          minimum: 1
          description: Maximum allowed click redirections before the link deactivates (requires Basic or Pro plan).
          example: 500
        maxClicks:
          type: integer
          description: CamelCase alias for `max_clicks`.
          example: 500
        utm_params:
          type: object
          description: Custom UTM analytics parameters (requires Basic or Pro plan for custom UTM tags).
          properties:
            utm_source:
              type: string
            utm_medium:
              type: string
            utm_campaign:
              type: string
            utm_term:
              type: string
            utm_content:
              type: string
        onExpiredRedirectTo:
          type: string
          format: uri
          description: Fallback URL if visited after link expiration.
        onMaxClicksReachRedirectTo:
          type: string
          format: uri
          description: Fallback URL if visited after click quota exhausted.
        whitelistedIPs:
          type: array
          items:
            type: string
          description: Optional IP allowlist for authorized redirects.
        blacklistedIPs:
          type: array
          items:
            type: string
          description: Optional IP blocklist.

    LinkModel:
      type: object
      required:
        - id
        - short_code
        - short_url
        - destination_url
        - status
        - expires_at
        - accepted_clicks
        - created_at
      properties:
        id:
          type: string
          format: uuid
          example: "550e8400-e29b-41d4-a716-446655440000"
        short_code:
          type: string
          example: "summer-launch"
        short_url:
          type: string
          format: uri
          example: "https://shortyy.com/summer-launch"
        destination_url:
          type: string
          format: uri
          example: "https://example.com/summer-campaign"
        creation_channel:
          type: string
          enum: [sdk, auth_web, guest_web]
          example: "sdk"
        status:
          type: string
          enum: [active, paused, blocked, expired]
          example: "active"
        expires_at:
          type: string
          format: date-time
          example: "2026-12-31T23:59:59.000Z"
        max_clicks:
          type: integer
          nullable: true
          example: 1000
        accepted_clicks:
          type: integer
          example: 42
        utm_params:
          type: object
          example: {}
        created_at:
          type: string
          format: date-time
          example: "2026-11-13T12:00:00.000Z"

    CreateLinkResponse:
      type: object
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          example: true
        data:
          $ref: '#/components/schemas/LinkModel'

    GetLinkResponse:
      type: object
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          example: true
        data:
          $ref: '#/components/schemas/LinkModel'

    ListLinksResponse:
      type: object
      required:
        - success
        - data
        - pagination
      properties:
        success:
          type: boolean
          example: true
        data:
          type: array
          items:
            $ref: '#/components/schemas/LinkModel'
        pagination:
          type: object
          required:
            - page
            - limit
            - total
            - totalPages
          properties:
            page:
              type: integer
              example: 1
            limit:
              type: integer
              example: 20
            total:
              type: integer
              example: 42
            totalPages:
              type: integer
              example: 3

    LinkAnalyticsResponse:
      type: object
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            link_id:
              type: string
              format: uuid
            short_code:
              type: string
            destination_url:
              type: string
            status:
              type: string
            accepted_clicks:
              type: integer
            max_clicks:
              type: integer
              nullable: true
            clicks_remaining:
              type: integer
              nullable: true
            last_clicked_at:
              type: string
              format: date-time
              nullable: true
            expires_at:
              type: string
              format: date-time
            utm_params:
              type: object
            created_at:
              type: string
              format: date-time
            breakdowns:
              type: object

    AnalyticsOverviewResponse:
      type: object
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            metrics:
              type: object
              properties:
                total_links:
                  type: integer
                active_links:
                  type: integer
                total_accepted_clicks:
                  type: integer
            top_links:
              type: array
              items:
                type: object
            breakdowns:
              type: object

    AccountProfileResponse:
      type: object
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            id:
              type: string
            user_id:
              type: string
            email:
              type: string
              nullable: true
            display_name:
              type: string
              nullable: true
            avatar_url:
              type: string
              nullable: true
            status:
              type: string
              example: "active"
            channel:
              type: string
              example: "sdk"
            created_at:
              type: string
              format: date-time

    PlanEntitlementsResponse:
      type: object
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            plan_id:
              type: string
              example: "free"
            plan_name:
              type: string
              example: "Free Developer"
            is_paid:
              type: boolean
              example: false
            user_status:
              type: string
              example: "active"
            capabilities:
              type: object
              properties:
                custom_alias:
                  type: boolean
                list_links:
                  type: boolean
                max_clicks:
                  type: boolean
                utm_builder:
                  type: boolean
                qr_download:
                  type: boolean
            web_max_lifetime_seconds:
              type: integer
              example: 86400
            sdk_max_lifetime_seconds:
              type: integer
              example: 10800

    UsageMetricsResponse:
      type: object
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            links_count:
              type: integer
            active_links_count:
              type: integer
            total_accepted_clicks:
              type: integer
            rate_limit:
              type: object
              properties:
                channel:
                  type: string
                  example: "sdk"
                limit:
                  type: integer
                  example: 4
                window_seconds:
                  type: integer
                  example: 75

    MessageResponse:
      type: object
      required:
        - success
        - message
      properties:
        success:
          type: boolean
          example: true
        message:
          type: string
          example: "Link deleted successfully"

    ErrorDetail:
      type: object
      required:
        - code
        - message
        - status
      properties:
        code:
          type: string
          description: Machine-readable uppercase error identifier.
          example: "VALIDATION_FAILED"
        message:
          type: string
          description: Human-readable error description.
          example: "Destination URL cannot be empty"
        status:
          type: integer
          description: HTTP status code.
          example: 400
        requestId:
          type: string
          description: Unique tracking ID for server-side debugging.
          example: "req-1731498000"
        retryAfterSeconds:
          type: integer
          description: Only returned when code is RATE_LIMIT_EXCEEDED. Specifies seconds to back off before retrying.
          example: 75
        details:
          type: object
          description: Optional structured validation errors.

    StandardErrorResponse:
      type: object
      required:
        - error
      properties:
        error:
          $ref: '#/components/schemas/ErrorDetail'

  responses:
    400ValidationError:
      description: Request validation failed (invalid destination URL, bad duration format, or malformed JSON).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/StandardErrorResponse'
          example:
            error:
              code: "VALIDATION_FAILED"
              message: "Destination URL must be a valid, public http:// or https:// address without credentials"
              status: 400
              requestId: "req-400-val"

    401Unauthorized:
      description: Authentication failed (missing, expired, or revoked API key).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/StandardErrorResponse'
          example:
            error:
              code: "INVALID_API_KEY"
              message: "The provided API key is invalid or malformed"
              status: 401
              requestId: "req-401-auth"

    403Forbidden:
      description: Forbidden (insufficient API key scope, account suspended, or plan entitlement required).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/StandardErrorResponse'
          example:
            error:
              code: "MISSING_API_SCOPE"
              message: "API key lacks required scope: links:delete"
              status: 403
              requestId: "req-403-scope"

    404NotFound:
      description: The requested link or resource was not found or does not belong to your account.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/StandardErrorResponse'
          example:
            error:
              code: "NOT_FOUND"
              message: "Link not found"
              status: 404
              requestId: "req-404-nf"

    429RateLimited:
      description: Token-bucket rate limit exceeded. Client must pause for the duration of `retryAfterSeconds` before retrying.
      headers:
        Retry-After:
          schema:
            type: integer
          description: Seconds to wait before retrying.
          example: 75
        X-RateLimit-Limit:
          schema:
            type: integer
          example: 4
        X-RateLimit-Remaining:
          schema:
            type: integer
          example: 0
        X-RateLimit-Reset:
          schema:
            type: string
            format: date-time
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/StandardErrorResponse'
          example:
            error:
              code: "RATE_LIMIT_EXCEEDED"
              message: "Rate limit exceeded: You're moving a little too fast. Try again in 75 seconds."
              status: 429
              requestId: "req-429-rl"
              retryAfterSeconds: 75

    500InternalError:
      description: Internal server error (safe error payload with tracking ID).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/StandardErrorResponse'
          example:
            error:
              code: "INTERNAL_SERVER_ERROR"
              message: "An unexpected internal error occurred. Please retry shortly."
              status: 500
              requestId: "req-500-err"

