openapi: 3.1.0
info:
  title: Littledata Agency API
  version: "1.0.0"
  description: |
    Read-only HTTP API for **agents acting on behalf of an agency user** (LLM agents,
    automations, third-party integrations). Authenticate with a per-user `ldat_*`
    bearer token minted from the Littledata UI at `/agency/api-tokens`.

    For human-readable narrative, error semantics, and token-lifecycle details see
    the [Agency API guide](./AGENCY_API.md).

    This spec is the source of truth for endpoint shapes. If it disagrees with the
    code in `src/handlers/api/agency/` or `src/handlers/api/auth/apiTokens/`, the
    spec is wrong — please open a PR to fix it.
servers:
  - url: https://alnmpsr7dc.execute-api.us-east-1.amazonaws.com/prod
    description: Production
tags:
  - name: Agency
    description: Data endpoints reachable with an API token or a logged-in session.
  - name: Tokens
    description: Token-management endpoints. Session-authed only — an API token cannot mint or revoke tokens.
security:
  - apiToken: []
paths:
  /agency/customers:
    get:
      tags: [Agency]
      summary: List customers the agency user can access
      description: |
        Returns the Littledata customers the calling user can access, either via
        verified `ShopifyUser` rows (Shopify access) or via the user's authorized
        Klaviyo accounts mapping to a customer through `customers.klaviyo.accountId`
        (direct Klaviyo access). Empty list when the user has neither verified
        `ShopifyUser` rows nor authorized Klaviyo accounts.
      operationId: listAgencyCustomers
      responses:
        "200":
          description: Array of customer summaries
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/AgencyCustomerSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "405": { $ref: "#/components/responses/MethodNotAllowed" }
  /agency/customers/{shopName}:
    get:
      tags: [Agency]
      summary: Get a single customer
      operationId: getAgencyCustomer
      parameters:
        - in: path
          name: shopName
          required: true
          schema: { type: string }
          description: Shop name, usually `*.myshopify.com`. URL-encode if it contains a slash or dot sequence.
      responses:
        "200":
          description: Customer detail
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AgencyCustomerDetail" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "405": { $ref: "#/components/responses/MethodNotAllowed" }
  /customers/{shopName}:
    get:
      tags: [Agency]
      summary: Get the store's own audit page payload
      description: |
        The customer record behind a store's audit page in the UI — the same page the store's own
        team sees. Access is the rule used by `GET /agency/customers`: a verified `ShopifyUser` row
        for this shop, or its Klaviyo account being one the user has connected.

        Richer than `GET /agency/customers/{shopName}` (value messaging copy, plan usage, connected
        Slack channels), minus everything internal: no health score or stage, no Intercom ids, no
        competitor install, no audit severity, no Slack invite link. Admin sessions get those fields;
        agency ones never do.
      operationId: getCustomerAuditPage
      parameters:
        - in: path
          name: shopName
          required: true
          schema: { type: string }
          description: Shop name, usually `*.myshopify.com`. URL-encode if it contains a slash or dot sequence.
      responses:
        "200":
          description: Customer detail as the store's own page reads it
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CustomerAuditPage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "405": { $ref: "#/components/responses/MethodNotAllowed" }
  /customers/{shopName}/audit-checks:
    get:
      tags: [Agency]
      summary: List the audit checks behind the store's page
      description: |
        Every current audit check for the store, filtered to its live destinations and deduped —
        exactly the rows the page's table renders. Same access rule as `GET /customers/{shopName}`.
      operationId: getCustomerAuditChecks
      parameters:
        - in: path
          name: shopName
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Audit checks plus any destinations whose access was revoked
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CustomerAuditChecks" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "405": { $ref: "#/components/responses/MethodNotAllowed" }
  /auth/api-tokens:
    post:
      tags: [Tokens]
      summary: Mint a new API token
      description: |
        Returns the plaintext `token` exactly once. The server stores only a SHA-256
        hash; if the caller loses the value, they must revoke and mint a new one.
      operationId: createApiToken
      security:
        - sessionAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  maxLength: 80
                  description: Human label shown in the token list.
                scope:
                  $ref: "#/components/schemas/ApiTokenScope"
      responses:
        "200":
          description: Newly minted token. Capture `token` now — it cannot be retrieved later.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MintedApiToken" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    get:
      tags: [Tokens]
      summary: List the caller's active API tokens
      operationId: listApiTokens
      security:
        - sessionAuth: []
      responses:
        "200":
          description: Active (non-revoked, non-expired) tokens for the calling user.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ApiTokenSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /auth/api-tokens/{id}:
    delete:
      tags: [Tokens]
      summary: Revoke an API token
      operationId: revokeApiToken
      security:
        - sessionAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        "204": { description: Revoked. Subsequent requests with this token return 401. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
components:
  securitySchemes:
    apiToken:
      type: http
      scheme: bearer
      bearerFormat: ldat
      description: |
        Per-user token of the form `ldat_<8-char-prefix>_<secret>`, minted in the
        Littledata UI at `/agency/api-tokens`. Agency tokens are `read`-scoped: the
        token never authenticates non-GET requests. Littledata admins may mint a
        `write`-scoped token for internal tooling (see `openapi-cip.yaml`).
    sessionAuth:
      type: http
      scheme: bearer
      description: Logged-in agency or admin session (cookie or session Bearer). Required for token-management endpoints.
  schemas:
    Destination:
      type: object
      properties:
        type: { type: string, description: "e.g. `ga4`, `meta`, `klaviyo`" }
        destinationId: { type: string }
      additionalProperties: true
    Phase:
      type: string
      enum: [trialing, paying, uninstalled, free, test]
    HealthStage:
      type: string
      enum: [Red, Yellow, Green]
    AgencyCustomerSummary:
      type: object
      required: [shopName]
      properties:
        _id: { type: string }
        shopName: { type: string }
        domain: { type: string, nullable: true }
        plan: { type: string, nullable: true }
        destinations:
          type: array
          items: { $ref: "#/components/schemas/Destination" }
        phase: { $ref: "#/components/schemas/Phase" }
        churnedAt:
          type: string
          format: date-time
          nullable: true
        healthScore: { type: number }
        healthStage: { $ref: "#/components/schemas/HealthStage" }
    ValueMetrics:
      type: object
      properties:
        bestTier: { type: string }
        incrementalTier: { type: string }
        roiTier: { type: string }
        monthlyPlanCost: { type: number }
        computedAt: { type: string, format: date-time }
    AgencyCustomerDetail:
      allOf:
        - $ref: "#/components/schemas/AgencyCustomerSummary"
        - type: object
          properties:
            planPrice: { type: number, description: Monthly plan cost in USD }
            parentShopName: { type: string }
            subsidiaryShopNames:
              type: array
              items: { type: string }
            churned: { type: boolean }
            valueMetrics: { $ref: "#/components/schemas/ValueMetrics" }
    CustomerAuditPage:
      type: object
      required: [shopName]
      description: |
        The store's own view of its customer record. Fields beyond those listed here exist for the
        page's own rendering; treat the shape as additive.
      properties:
        _id: { type: string }
        shopName: { type: string }
        domain: { type: string, nullable: true }
        plan: { type: string, nullable: true }
        planPrice: { type: number, description: Monthly plan cost in USD }
        orderCount: { type: number, description: Orders counted in the current billing period }
        orderLimit: { type: number, description: Orders the plan includes before overage }
        overageFee: { type: number }
        destinations:
          type: array
          items: { $ref: "#/components/schemas/Destination" }
        connectionIssues:
          type: array
          description: Destinations configured but not resolved (e.g. a GA4 property we cannot read).
          items:
            type: object
            properties:
              type: { type: string }
              destinationId: { type: string }
              reason: { type: string }
            additionalProperties: true
        phase: { $ref: "#/components/schemas/Phase" }
        churned: { type: boolean }
        lastAuditedAt: { type: string, format: date-time }
        trialEndDate: { type: string, format: date-time }
        parentShopName: { type: string }
        subsidiaryShopNames:
          type: array
          items: { type: string }
        klaviyoAccountId: { type: string }
        googleAdsCustomerId: { type: string }
        valueMetrics: { $ref: "#/components/schemas/ValueMetrics" }
        valueMessages:
          type: object
          description: Customer-facing value and ROI copy, built server-side. Render it verbatim.
          additionalProperties: true
        slacks:
          type: array
          description: Slack channels receiving this store's alerts.
          items:
            type: object
            properties:
              connectionId: { type: string }
              teamName: { type: string }
              channel: { type: string }
              enabled: { type: boolean }
            additionalProperties: true
      additionalProperties: true
    CustomerAuditChecks:
      type: object
      required: [shopName, auditChecks]
      properties:
        shopName: { type: string }
        auditChecks:
          type: array
          items:
            type: object
            properties:
              check: { type: string, description: Check name, e.g. `orderThroughput` }
              status:
                type: integer
                description: >
                  AuditStatus wire enum: -1 UNKNOWN, 0 SUCCESS, 1 INFO, 2 WARNING, 3 ERROR.
                enum: [-1, 0, 1, 2, 3]
              entityType: { type: string }
              entityId: { type: string }
              updatedAt: { type: string, format: date-time }
            additionalProperties: true
        revokedDestinations:
          type: array
          description: Destinations whose access we have lost, so their checks cannot run.
          items:
            type: object
            properties:
              entityType: { type: string }
              entityId: { type: string }
              reason: { type: string }
    ApiTokenScope:
      type: string
      enum: [read, write]
      default: read
      description: |
        `read` (default) never authenticates a non-GET request. `write` may only be minted
        from an admin session (403 for an agency session) and authenticates every method the
        admin's own session could; it exists for Littledata's internal agents.
    MintedApiToken:
      type: object
      required: [id, token, prefix, name, scope, createdAt, expiresAt]
      properties:
        id: { type: string }
        token:
          type: string
          description: Plaintext token. Returned only at creation — store it immediately.
        prefix: { type: string, description: 8-char public prefix shown in token lists. }
        name: { type: string }
        scope: { $ref: "#/components/schemas/ApiTokenScope" }
        createdAt: { type: string, format: date-time }
        expiresAt: { type: string, format: date-time, description: 12 months after creation. }
    ApiTokenSummary:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        prefix: { type: string }
        scope: { $ref: "#/components/schemas/ApiTokenScope" }
        createdAt: { type: string, format: date-time }
        expiresAt: { type: string, format: date-time }
        lastUsedAt: { type: string, format: date-time, nullable: true }
    Error:
      type: object
      properties:
        message: { type: string }
  responses:
    BadRequest:
      description: Malformed request (e.g. missing required field).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Missing, malformed, expired, or revoked credentials.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: Authenticated, but not authorized for this resource.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: No record exists for the supplied identifier.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    MethodNotAllowed:
      description: Non-GET method on an API-token-authed request.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
