openapi: 3.1.0
info:
  title: EmailQA API
  version: 1.0.0
  description: |
    Render emails on real email clients — real Outlook on Windows, real iPhones
    and Android devices — and fetch light/dark mode screenshots programmatically.
    Upload HTML, trigger renders, poll or receive webhooks, and share preview
    links your team can review and approve in the EmailQA UI.

    Human-readable documentation: https://emailqa.live/docs/api

    API access is available on the Business plan: https://emailqa.live/pricing
  contact:
    name: EmailQA
    url: https://emailqa.live
servers:
  - url: https://emailqa.live
security:
  - bearerAuth: []

tags:
  - name: Clients
    description: The render client catalog
  - name: Projects
    description: Projects hold one email, its versions, comments, and renders
  - name: Versions
    description: HTML version history
  - name: Renders
    description: Trigger renders and fetch results
  - name: Comments
    description: Review comments on versions

paths:
  /api/v1/clients:
    get:
      tags: [Clients]
      operationId: listClients
      summary: List available render clients
      description: >
        The live client catalog. No authentication required. Clients under
        maintenance are listed but rejected by the render endpoint until
        re-enabled.
      security: []
      responses:
        '200':
          description: The client catalog
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/RenderClient'

  /api/v1/projects:
    get:
      tags: [Projects]
      operationId: listProjects
      summary: List projects
      parameters:
        - name: archived
          in: query
          schema: { type: boolean, default: false }
        - name: limit
          in: query
          schema: { type: integer, default: 50 }
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
      responses:
        '200':
          description: Projects accessible to this key
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ProjectSummary'
                  meta:
                    type: object
                    properties:
                      total: { type: integer }
                      limit: { type: integer }
                      offset: { type: integer }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
    post:
      tags: [Projects]
      operationId: createProject
      summary: Create a project
      description: >
        Requires the `write` scope. If `html` is provided, version 1 is created
        from it (max 5MB). API-created projects are **private by default** —
        pass `isPrivate: false` to make the preview link viewable by anyone
        who has it. Rate limit: 30 project creations per hour.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, example: Spring campaign }
                description: { type: string }
                html:
                  type: string
                  description: Email HTML for version 1 (max 5MB)
                isPrivate:
                  type: boolean
                  default: true
      responses:
        '201':
          description: Project created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProjectCreated'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/projects/{slug}:
    parameters:
      - $ref: '#/components/parameters/slug'
    get:
      tags: [Projects]
      operationId: getProject
      summary: Get project details
      responses:
        '200':
          description: Project details with versions, tags, and comment stats
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProjectDetail'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Projects]
      operationId: updateProject
      summary: Update a project
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                description: { type: string }
                isPrivate: { type: boolean }
                isArchived: { type: boolean }
      responses:
        '200':
          description: Updated project
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Projects]
      operationId: deleteProject
      summary: Delete a project
      description: Owner only. Requires the `delete` scope.
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/v1/projects/{slug}/versions:
    parameters:
      - $ref: '#/components/parameters/slug'
    get:
      tags: [Versions]
      operationId: listVersions
      summary: List versions
      responses:
        '200':
          description: Version history, newest first
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Version'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    post:
      tags: [Versions]
      operationId: createVersion
      summary: Upload a new HTML version
      description: >
        Requires the `write` scope. Max 5MB. Rate limit: 60 uploads per hour.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [html]
              properties:
                html: { type: string }
      responses:
        '201':
          description: Version created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      versionNumber: { type: integer }
                      createdAt: { type: string, format: date-time }
                      previewUrl: { type: string, format: uri }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/usage:
    get:
      tags: [Renders]
      operationId: getUsage
      summary: Current quota and rate-limit state
      description: >
        Check your monthly preview quota and burst-limit state without
        triggering a render or waiting for a 402.
      responses:
        '200':
          description: Quota and rate-limit state
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      monthlyPreviews:
                        $ref: '#/components/schemas/MonthlyPreviews'
                      rateLimits:
                        type: object
                        properties:
                          maxConcurrentJobs: { type: integer, example: 2 }
                          maxJobsPerHour: { type: integer, example: 30 }
                          activeJobs: { type: integer, example: 0 }
                          jobsInLastHour: { type: integer, example: 3 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /api/v1/projects/{slug}/renders:
    parameters:
      - $ref: '#/components/parameters/slug'
    post:
      tags: [Renders]
      operationId: createRender
      summary: Trigger email client renders
      description: >
        Requires the `write` scope and API access (Business plan). Renders the
        given version (default: latest) on the requested clients (default: the
        full available fleet). Returns `202` with a queued job, or `200` with
        completed results immediately when identical HTML was rendered
        recently (`cached: true`) or in sandbox mode (`test: true`). Every
        response includes your monthly preview quota. Limits: 2 concurrent
        jobs, 30 jobs per hour, 1,000 previews/month included (1 preview =
        1 successful screenshot; failed renders and test renders are free).
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string, maxLength: 256 }
          description: >
            Scoped to your account. A retried request with the same key
            returns the original job (with `replayed: true`) instead of
            rendering — and billing — twice.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                version:
                  type: integer
                  description: Version number (defaults to latest)
                clients:
                  type: array
                  description: Client ids from GET /api/v1/clients
                  items: { type: string }
                  example: [outlook-desktop, gmail-ios]
                test:
                  type: boolean
                  default: false
                  description: >
                    Sandbox mode — instantly-completed job with fixture
                    screenshots. No fleet time, no quota, no email sent;
                    the render.completed webhook still fires with test: true.
      responses:
        '202':
          description: Render job queued
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RenderJobResponse'
        '200':
          description: Cache hit — completed results returned synchronously
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RenderJobResponse'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: Plan limit reached (monthly preview quota or render entitlement)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/QuotaError'
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503':
          description: Rendering service not available
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /api/v1/renders/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
        description: Render job id (rj_...)
        example: rj_G6kHhvruMlPO6ibM
    get:
      tags: [Renders]
      operationId: getRender
      summary: Get render job status and results
      description: >
        Results stream in per client while the job runs. Screenshot URLs are
        presigned and expire after 1 hour — re-fetch the job for fresh links.
        Per-client failures are normal; check each result's `status`.
      responses:
        '200':
          description: Job status and results
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/RenderJob'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Renders]
      operationId: cancelRender
      summary: Cancel a queued or in-progress render job
      responses:
        '200':
          description: Cancelled
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Job already finished and cannot be cancelled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /api/v1/projects/{slug}/comments:
    parameters:
      - $ref: '#/components/parameters/slug'
    get:
      tags: [Comments]
      operationId: listComments
      summary: List review comments
      parameters:
        - name: version
          in: query
          schema: { type: integer }
          description: Version number (defaults to latest)
        - name: status
          in: query
          schema: { type: string, enum: [open, resolved] }
      responses:
        '200':
          description: Top-level comments with authors and reply counts
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    post:
      tags: [Comments]
      operationId: createComment
      summary: Post a comment
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content]
              properties:
                content: { type: string }
                type:
                  type: string
                  enum: [pin, box, general]
                  default: general
                pinX:
                  type: number
                  description: Percentage X coordinate for pin/box comments
                pinY:
                  type: number
                  description: Percentage Y coordinate for pin/box comments
                boxWidth: { type: number }
                boxHeight: { type: number }
                versionNumber: { type: integer }
      responses:
        '201':
          description: Comment created
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

webhooks:
  render.completed:
    post:
      summary: Render job finished
      description: >
        Sent to your configured webhook URLs when a render job completes.
        Headers: `X-EmailQA-Event`, `X-EmailQA-Delivery` (uuid),
        `X-EmailQA-Timestamp`, and — when your webhook has a secret —
        `X-EmailQA-Signature: sha256=<hex HMAC-SHA256 of the raw body>`.
        Payloads contain no screenshot URLs; call GET /api/v1/renders/{id}
        for fresh presigned links.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                event: { type: string, const: render.completed }
                timestamp: { type: string, format: date-time }
                data:
                  type: object
                  properties:
                    render:
                      type: object
                      properties:
                        id: { type: string, example: rj_G6kHhvruMlPO6ibM }
                        status: { type: string, enum: [completed, error] }
                        cached: { type: boolean }
                        project:
                          type: object
                          properties:
                            slug: { type: string }
                            name: { type: string }
                        versionNumber: { type: integer }
                        requestedClients:
                          type: array
                          items: { type: string }
                        results:
                          type: array
                          items:
                            type: object
                            properties:
                              client: { type: string }
                              status: { type: string }
                              error: { type: string }
      responses:
        '200':
          description: Return any 2xx to acknowledge delivery

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >
        API key from Settings → API Keys, sent as `Authorization: Bearer eq_...`.
        Keys carry scopes: `read` (fetch data) and `write` (create, upload,
        render, cancel, comment).

  parameters:
    slug:
      name: slug
      in: path
      required: true
      schema: { type: string }
      description: Project slug
      example: n3j6ip1vb8kj

  responses:
    BadRequest:
      description: Invalid request (unknown clients include validClients in the body)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unauthorized:
      description: Missing, invalid, or expired API key
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Forbidden:
      description: Key lacks the required scope, no access to this project, or API access not enabled
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: Not found (existence of other tenants' resources is not revealed)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    PayloadTooLarge:
      description: HTML larger than 5MB
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    RateLimited:
      description: Rate limited
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

  schemas:
    Error:
      type: object
      properties:
        error:
          type: string
          description: Human-readable message. Treat as opaque text.

    QuotaError:
      allOf:
        - $ref: '#/components/schemas/Error'
        - type: object
          properties:
            monthlyPreviews:
              $ref: '#/components/schemas/MonthlyPreviews'

    MonthlyPreviews:
      type: object
      description: Your monthly included-preview quota status
      properties:
        used: { type: integer, example: 4 }
        limit: { type: integer, example: 1000 }
        remaining: { type: integer, example: 996 }

    RenderClient:
      type: object
      properties:
        id: { type: string, example: outlook-desktop }
        label: { type: string, example: Outlook Windows (Classic) }
        category: { type: string, enum: [Desktop, Mobile] }
        os: { type: [string, 'null'], enum: [Windows, Mac, iOS, Android, null] }
        darkMode:
          type: boolean
          description: Whether this client returns separate light and dark screenshots
        status: { type: string, enum: [available, maintenance] }

    ProjectSummary:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        slug: { type: string }
        description: { type: [string, 'null'] }
        isPrivate: { type: boolean }
        isArchived: { type: boolean }
        latestVersion: { type: [integer, 'null'] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    ProjectCreated:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        slug: { type: string }
        description: { type: [string, 'null'] }
        isPrivate: { type: boolean }
        createdAt: { type: string, format: date-time }
        previewUrl: { type: string, format: uri }

    ProjectDetail:
      allOf:
        - $ref: '#/components/schemas/ProjectSummary'
        - type: object
          properties:
            versions:
              type: array
              items:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  versionNumber: { type: integer }
                  createdAt: { type: string, format: date-time }
                  isLocked: { type: boolean }
            commentStats:
              type: object
              properties:
                total: { type: integer }
                open: { type: integer }
                resolved: { type: integer }
            previewUrl: { type: string, format: uri }

    Version:
      type: object
      properties:
        id: { type: string, format: uuid }
        versionNumber: { type: integer }
        createdAt: { type: string, format: date-time }
        isLocked: { type: boolean }
        lockedAt: { type: [string, 'null'], format: date-time }
        uploadedBy:
          type: [object, 'null']
          properties:
            id: { type: string }
            name: { type: [string, 'null'] }
            email: { type: string }

    ClientResult:
      type: object
      properties:
        client:
          type: string
          description: >
            Variant-level client id — dark-mode-capable clients return
            separate entries like outlook-desktop-light / outlook-desktop-dark.
          example: gmail-web
        status: { type: string, enum: [success, error, cancelled] }
        error:
          type: string
          description: Present on failures. Opaque text — do not parse.
        screenshotUrl:
          type: string
          format: uri
          description: Presigned URL, expires after screenshotUrlExpiresInSeconds
        screenshotUrlExpiresInSeconds: { type: integer, example: 3600 }

    RenderJob:
      type: object
      properties:
        id: { type: string, example: rj_G6kHhvruMlPO6ibM }
        status:
          type: string
          enum: [queued, processing, completed, error, cancelled]
        cached: { type: boolean }
        test:
          type: boolean
          description: Present and true for sandbox-mode jobs
        projectSlug: { type: string }
        versionNumber: { type: integer }
        requestedClients:
          type: array
          items: { type: string }
        queuePosition: { type: integer, description: Present while queued (0 = next) }
        estimatedWaitSeconds: { type: integer, description: Present while queued }
        completedCount: { type: integer, description: Present while processing }
        totalCount: { type: integer, description: Present while processing }
        error: { type: string }
        results:
          type: array
          items:
            $ref: '#/components/schemas/ClientResult'
        createdAt: { type: string, format: date-time }
        completedAt: { type: [string, 'null'], format: date-time }

    RenderJobResponse:
      type: object
      properties:
        data:
          $ref: '#/components/schemas/RenderJob'
        monthlyPreviews:
          $ref: '#/components/schemas/MonthlyPreviews'
        replayed:
          type: boolean
          description: Present and true when an Idempotency-Key replay returned an existing job
