openapi: 3.0.3
info:
  title: MetrixNest API
  version: "1.0"
  description: |
    MetrixNest is a product health and decision-support platform for indie SaaS founders.
    This API is intentionally small and opinionated.

servers:
  - url: /

paths:
  /api/v1/events:
    post:
      summary: Ingest a canonical event (append-only)
      description: |
        Send server-side events from your app. No client SDK required.
        Events are project-scoped (product-scoped), append-only, and immutable.

        The request accepts a flat `event_type` (e.g. `user_signed_up`). The server
        auto-splits this into `event_type` + `action` in the response (e.g. `event_type: "user"`,
        `action: "signed_up"`, `canonical_name: "user_signed_up"`).
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EventIngestRequest"
      responses:
        "201":
          description: Event created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EventIngestResponse"
        "200":
          description: Event accepted (idempotent replay)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EventIngestResponse"
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "422":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /api/v2/events:
    post:
      summary: Ingest an event (custom or canonical)
      description: |
        Send server-side events from your app. Supports **custom** event types.

        **Preferred format** — send `event_type` (entity noun) and `action` (verb) separately:
        ```json
        { "event_type": "subscription", "action": "started", ... }
        ```

        **Unified format** — send a single `event` string using dot, colon, or underscore as separator:
        ```json
        { "event": "user.signed_up", ... }
        ```
        The server auto-splits this into `event_type` and `action`. No deprecation headers.

        **Legacy format** — a flat `event_type` string is still accepted for backward compatibility.
        When `action` is omitted, the server auto-splits the flat value (e.g. `project_created` →
        `event_type: "project"`, `action: "created"`). Legacy requests receive deprecation headers:
        - `Deprecation: true`
        - `Sunset: 2026-09-01`

        Naming rules:
        - `event_type`: `^[a-z][a-z0-9_]*$` (entity noun, e.g. `subscription`, `billing`)
        - `action`: `^[a-z][a-z0-9_]*$` (verb, e.g. `started`, `invoice_paid`)
        - Legacy flat pattern: `^[a-z][a-z0-9_]*(?::[a-z0-9_]+)*$`

        Properties:
        - Use `properties` for structured key/value data
        - `metadata` is accepted as an alias of `properties`
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EventIngestRequestV2"
      responses:
        "201":
          description: Event created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EventIngestResponse"
        "200":
          description: Event accepted (idempotent replay)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EventIngestResponse"
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "422":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /api/v2/events/batch:
    post:
      summary: Batch ingest events
      description: |
        Ingest multiple events in a single request. Each event supports the same
        formats as the single event endpoint: explicit `event_type` + `action`,
        unified `event` field, or legacy flat `event_type` (with deprecation headers).
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EventIngestBatchRequestV2"
      responses:
        "201":
          description: One or more events created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EventIngestBatchResponseV2"
        "200":
          description: All events accepted (idempotent replay)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EventIngestBatchResponseV2"
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "422":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /api/v2/identify:
    post:
      summary: Identify a customer user (user_customer)
      description: |
        Upsert a user in your product and attach identifiers (email, internal user id, billing ids) and traits.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/IdentifyRequestV2"
      responses:
        "201":
          description: User created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IdentifyResponseV2"
        "200":
          description: User updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IdentifyResponseV2"
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "409":
          description: Identifier conflict
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "422":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /api/v2/alias:
    post:
      summary: Merge two user_customers
      description: |
        Merge two user_customers (typically anonymous → known). Identifiers are moved and traits are merged.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AliasRequestV2"
      responses:
        "200":
          description: Merge succeeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AliasResponseV2"
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "422":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: APIKey
  schemas:
    EventIngestRequest:
      type: object
      additionalProperties: false
      required:
        - event_type
        - subject_type
        - subject_id
        - occurred_at
      properties:
        event_type:
          type: string
          description: |
            Canonical event type (flat format). The server auto-splits this
            into separate `event_type` and `action` fields in the response.
          enum:
            - user_signed_up
            - user_active
            - session_start
            - session_end
            - user_deleted
        subject_type:
          type: string
          enum: [user, account, subscription]
        subject_id:
          type: string
        occurred_at:
          type: string
          format: date-time
        metadata:
          type: object
          default: {}
        external_id:
          type: string
          description: Optional idempotency key for client retries

    EventIngestRequestV2:
      type: object
      additionalProperties: false
      description: |
        Three input formats (resolved in priority order):
        1. **Explicit**: `event_type` + `action` as separate fields (preferred)
        2. **Unified**: single `event` field with dot/colon/underscore separator
        3. **Legacy**: flat `event_type` without `action` (deprecated)

        One of `event` or `event_type` is required.
      required:
        - subject_type
        - subject_id
        - occurred_at
      properties:
        event:
          type: string
          description: |
            Unified event name using dot, colon, or underscore as separator
            (e.g. `user.signed_up`, `billing:invoice_paid`).
            The server auto-splits this into `event_type` and `action`.
            No deprecation headers are returned for this format.
          pattern: "^[a-z][a-z0-9_.:-]{0,127}$"
          maxLength: 128
        event_type:
          type: string
          description: |
            Entity noun (e.g. `subscription`, `user`, `billing`).
            Required when `action` is provided. When used without `action`,
            treated as legacy flat value and auto-split by the server.
          pattern: "^[a-z][a-z0-9_]*(?::[a-z0-9_]+)*$"
        action:
          type: string
          description: |
            Verb describing what happened (e.g. `started`, `signed_up`).
            Omit to use legacy flat format (deprecated, sunset 2026-09-01).
          pattern: "^[a-z][a-z0-9_]*$"
          maxLength: 64
        subject_type:
          type: string
          enum: [user, account, subscription]
        subject_id:
          type: string
        occurred_at:
          type: string
          format: date-time
        properties:
          type: object
          default: {}
          description: Structured properties for this event
        metadata:
          type: object
          default: {}
          description: Alias of properties (accepted for compatibility)
        external_id:
          type: string
          description: Optional idempotency key for client retries

    EventIngestBatchRequestV2:
      type: object
      additionalProperties: false
      required: [events]
      properties:
        events:
          type: array
          minItems: 1
          items:
            $ref: "#/components/schemas/EventIngestRequestV2"

    EventIngestBatchEventV2:
      type: object
      required:
        - uuid
        - event_type
        - action
        - canonical_name
        - source
        - subject_type
        - subject_id
        - occurred_at
        - created
      properties:
        uuid:
          type: string
          format: uuid
        event_type:
          type: string
          description: Entity noun (e.g. `invite`, `payment`)
        action:
          type: string
          description: Verb (e.g. `sent`, `succeeded`)
        canonical_name:
          type: string
          description: Joined `event_type` + `action` (e.g. `invite_sent`)
        source:
          type: string
          enum: [api]
        subject_type:
          type: string
        subject_id:
          type: string
        occurred_at:
          type: string
          format: date-time
        external_id:
          type: string
          nullable: true
        created:
          type: boolean

    EventIngestBatchResponseV2:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [events]
          properties:
            events:
              type: array
              items:
                $ref: "#/components/schemas/EventIngestBatchEventV2"

    EventIngestResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required:
            - uuid
            - event_type
            - action
            - canonical_name
            - source
            - subject_type
            - subject_id
            - occurred_at
          properties:
            uuid:
              type: string
              format: uuid
            event_type:
              type: string
              description: Entity noun (e.g. `user`, `subscription`)
            action:
              type: string
              description: Verb (e.g. `signed_up`, `started`)
            canonical_name:
              type: string
              description: Joined `event_type` + `action` (e.g. `user_signed_up`)
            source:
              type: string
              enum: [api]
            subject_type:
              type: string
            subject_id:
              type: string
            occurred_at:
              type: string
              format: date-time
            external_id:
              type: string
              nullable: true

    IdentifierPair:
      type: object
      additionalProperties: false
      required: [type, value]
      properties:
        type:
          type: string
        value:
          type: string

    IdentifyRequestV2:
      type: object
      additionalProperties: false
      required: [external_id]
      properties:
        external_id:
          type: string
        identifiers:
          description: Identifier map or list (email, internal user id, billing ids, etc.)
          oneOf:
            - type: object
              additionalProperties:
                type: string
            - type: array
              items:
                $ref: "#/components/schemas/IdentifierPair"
        traits:
          type: object
          default: {}

    UserCustomerIdentifier:
      type: object
      required: [type, value]
      properties:
        type:
          type: string
        value:
          type: string

    UserCustomer:
      type: object
      required: [uuid, external_id, traits, identifiers]
      properties:
        uuid:
          type: string
          format: uuid
        external_id:
          type: string
        traits:
          type: object
        identifiers:
          type: array
          items:
            $ref: "#/components/schemas/UserCustomerIdentifier"

    IdentifyResponseV2:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/UserCustomer"

    AliasRequestV2:
      type: object
      additionalProperties: false
      required: [from_external_id, to_external_id]
      properties:
        from_external_id:
          type: string
        to_external_id:
          type: string

    AliasResponseV2:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required:
            - merged_from_external_id
            - merged_into_external_id
            - moved_identifiers
            - removed_duplicate_identifiers
            - user_customer
          properties:
            merged_from_external_id:
              type: string
            merged_into_external_id:
              type: string
            moved_identifiers:
              type: integer
            removed_duplicate_identifiers:
              type: integer
            user_customer:
              $ref: "#/components/schemas/UserCustomer"

    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
            message:
              type: string
            details:
              nullable: true

