openapi: 3.1.0
info:
  title: MailCull Public API
  version: "1.0.0"
  description: |
    Versioned REST API for MailCull single-email validation and asynchronous bulk job workflows.

    ## Quick start

    1. **Get a key** — sign in at [mailcull.io](https://mailcull.io/login), open **Account → API access**, and create a key. Pro plan is required for live keys; test keys are free.
    2. **Authenticate** — pass the key as a Bearer token on every request:

       ```
       Authorization: Bearer YOUR_API_KEY
       ```

    3. **Validate one email** to verify your setup:

       ```bash
       curl -X POST https://mailcull.io/api/v1/email-validations \
         -H "Authorization: Bearer YOUR_API_KEY" \
         -H "Content-Type: application/json" \
         -H "Idempotency-Key: smoke-001" \
         -d '{"email":"sales@example.com","scanMode":"quick"}'
       ```

    4. **Check `/me`** to see your plan, quota, and rate limits in one place — useful before kicking off a big job.

    ## Conventions

    - **Format:** every request and response body is JSON unless explicitly multipart (the bulk-upload endpoint).
    - **Errors:** every 4xx/5xx returns an `{"error": {"code", "message", "docs_url"}}` envelope. Switch on `code`, not `message`.
    - **Rate limits:** every response carries `X-RateLimit-*` headers. The exact ceiling depends on your plan — check `/api/v1/me`.
    - **Idempotency:** mutating endpoints accept an `Idempotency-Key` header. Replay the same key within 24 hours and you get the same response. Safe for retrying after network blips.
    - **Test mode:** keys created in test mode return deterministic results without consuming validation units or hitting recipient mail servers. Use them for CI and development.

    Effective plan access and quota are surfaced through `/api/v1/me`; write routes may return `plan_upgrade_required` or `quota_exceeded` when billing or plan policy changes reduce available access.
servers:
  - url: /api/v1
security:
  - bearerApiKey: []
tags:
  - name: Account
  - name: Validations
  - name: Jobs
  - name: Exports
components:
  parameters:
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      required: true
      schema:
        type: string
        maxLength: 255
    Page:
      in: query
      name: page
      schema:
        type: integer
        minimum: 1
        default: 1
    LimitJobs:
      in: query
      name: limit
      schema:
        type: integer
        minimum: 1
        maximum: 500
        default: 100
    LimitResults:
      in: query
      name: limit
      schema:
        type: integer
        minimum: 1
        maximum: 1000
        default: 100
    JobId:
      in: path
      name: jobId
      required: true
      schema:
        type: string
    ExportId:
      in: path
      name: exportId
      required: true
      schema:
        type: string
    ValidationId:
      in: path
      name: id
      required: true
      description: Single-validation record id, returned by POST /email-validations.
      schema:
        type: string
  headers:
    XRateLimitLimit:
      schema:
        type: integer
    XRateLimitRemaining:
      schema:
        type: integer
    XRateLimitReset:
      schema:
        type: integer
        description: Unix timestamp in seconds
    RetryAfter:
      schema:
        type: integer
  securitySchemes:
    bearerApiKey:
      type: http
      scheme: bearer
      bearerFormat: API key
  schemas:
    ErrorCode:
      type: string
      description: |
        Stable, machine-readable identifier for the failure. Clients should
        switch on this value (NOT the human-readable `message`, which may
        change). New codes may be added over time — treat unknown values
        as a generic failure.
      enum:
        - unauthorized
        - forbidden
        - not_found
        - validation_error
        - rate_limited
        - quota_exceeded
        - plan_upgrade_required
        - idempotency_conflict
        - job_not_found
        - export_not_found
        - invalid_csv
        - deep_scan_unavailable
        - internal_error
    ErrorEnvelope:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, docs_url]
          properties:
            code:
              $ref: '#/components/schemas/ErrorCode'
            message:
              type: string
            docs_url:
              type: string
              format: uri-reference
            retryAfterSeconds:
              type: integer
              nullable: true
    Pagination:
      type: object
      required: [page, limit, total_count, has_more, cursor]
      properties:
        page:
          type: integer
        limit:
          type: integer
        total_count:
          type: integer
        has_more:
          type: boolean
        cursor:
          nullable: true
    PagedResponse:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: {}
        meta:
          type: object
          required: [pagination]
          properties:
            pagination:
              $ref: '#/components/schemas/Pagination'
    ScanMode:
      type: string
      enum: [quick, deep]
    JobStatus:
      type: string
      enum: [queued, processing, completed, failed, cancelled]
    FinalStatus:
      type: string
      enum: [deliverable, risky, undeliverable, unknown]
    ValidationLifecycleStatus:
      type: string
      enum: [completed, pending_enrichment]
    ApiKeyMode:
      type: string
      enum: [live, test]
    ValidationResult:
      type: object
      properties:
        id:
          type: integer
        email:
          type: string
        scanMode:
          $ref: '#/components/schemas/ScanMode'
        isTest:
          type: boolean
        enrichmentStatus:
          type: string
          nullable: true
        enrichmentStartedAt:
          type: string
          format: date-time
          nullable: true
        enrichmentCompletedAt:
          type: string
          format: date-time
          nullable: true
        finalStatus:
          $ref: '#/components/schemas/FinalStatus'
        explanation:
          type: string
        score:
          type: integer
        reason:
          type: string
          nullable: true
        smtpStatus:
          type: string
          nullable: true
        smtpReason:
          type: string
          nullable: true
        smtpScoreDelta:
          type: integer
          nullable: true
        smtpCheckedAt:
          type: string
          format: date-time
          nullable: true
        provisional:
          type: boolean
        reasonFlags:
          type: array
          items:
            type: string
        createdAt:
          type: string
          format: date-time
          nullable: true
    ValidationCreateRequest:
      type: object
      required: [email]
      properties:
        email:
          type: string
        scanMode:
          $ref: '#/components/schemas/ScanMode'
    ValidationCreateResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [id, status, result]
          properties:
            id:
              type: integer
              nullable: true
            status:
              $ref: '#/components/schemas/ValidationLifecycleStatus'
            result:
              $ref: '#/components/schemas/ValidationResult'
    JobSummary:
      type: object
      properties:
        deliverable:
          type: integer
        risky:
          type: integer
        undeliverable:
          type: integer
        unknown:
          type: integer
    Job:
      type: object
      properties:
        id:
          type: string
        parentJobId:
          type: string
          nullable: true
        fileName:
          type: string
          nullable: true
        status:
          $ref: '#/components/schemas/JobStatus'
        phase:
          type: string
          nullable: true
        cancelRequested:
          type: boolean
        cancelRequestedAt:
          type: string
          format: date-time
          nullable: true
        totalRows:
          type: integer
        totalMatchesFound:
          type: integer
        uniqueEmailsFound:
          type: integer
        validatedCount:
          type: integer
        summary:
          $ref: '#/components/schemas/JobSummary'
        error:
          type: string
          nullable: true
        startedAt:
          type: string
          format: date-time
          nullable: true
        lastHeartbeatAt:
          type: string
          format: date-time
          nullable: true
        createdAt:
          type: string
          format: date-time
          nullable: true
        updatedAt:
          type: string
          format: date-time
          nullable: true
        completedAt:
          type: string
          format: date-time
          nullable: true
        scanMode:
          $ref: '#/components/schemas/ScanMode'
        isTest:
          type: boolean
        webhookId:
          nullable: true
        smtpEnrichment:
          type: object
          nullable: true
        confidence:
          type: object
          nullable: true
    JobCreateRequest:
      type: object
      properties:
        rows:
          type: array
          description: JSON-based bulk input alternative to multipart CSV upload
          items:
            oneOf:
              - type: string
              - type: object
        scanMode:
          $ref: '#/components/schemas/ScanMode'
    JobCreateResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [job]
          properties:
            job:
              $ref: '#/components/schemas/Job'
    JobResult:
      type: object
      properties:
        email:
          type: string
        finalStatus:
          $ref: '#/components/schemas/FinalStatus'
        score:
          type: integer
        reason:
          type: string
          nullable: true
        reasonFlags:
          type: array
          items:
            type: string
        smtpStatus:
          type: string
          nullable: true
        smtpReason:
          type: string
          nullable: true
        smtpScoreDelta:
          type: integer
          nullable: true
        smtpCheckedAt:
          type: string
          format: date-time
          nullable: true
        smtpFlags:
          type: array
          items:
            type: string
        provisional:
          type: boolean
        explanation:
          type: string
        occurrences:
          type: integer
        firstFoundAt:
          type: object
          nullable: true
          properties:
            row:
              type: integer
            column:
              type: integer
    Export:
      type: object
      properties:
        id:
          type: string
        jobId:
          type: string
        status:
          type: string
        format:
          type: string
          enum: [csv, json]
        scope:
          type: string
        filters:
          type: object
        fileName:
          type: string
          nullable: true
        totalRows:
          type: integer
        exportedRows:
          type: integer
        progressPct:
          type: integer
        sizeBytes:
          type: integer
        startedAt:
          type: string
          format: date-time
          nullable: true
        createdAt:
          type: string
          format: date-time
          nullable: true
        updatedAt:
          type: string
          format: date-time
          nullable: true
        completedAt:
          type: string
          format: date-time
          nullable: true
        error:
          type: string
          nullable: true
    ExportCreateRequest:
      type: object
      properties:
        scope:
          type: string
          default: current
        format:
          type: string
          enum: [csv, json]
          default: csv
        status:
          type: string
        reason:
          type: string
        q:
          type: string
        sortBy:
          type: string
        sortDir:
          type: string
          enum: [asc, desc]
    MeResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          properties:
            user:
              type: object
              properties:
                id:
                  type: string
                username:
                  type: string
                email:
                  type: string
                planTier:
                  type: string
            apiKey:
              type: object
              properties:
                id:
                  type: string
                label:
                  type: string
                mode:
                  $ref: '#/components/schemas/ApiKeyMode'
                prefix:
                  type: string
                last4:
                  type: string
                scopes:
                  type: array
                  items:
                    type: string
                lastUsedAt:
                  type: string
                  format: date-time
                  nullable: true
            quota:
              type: object
              description: Per-API-key validation quota for the current billing period.
              properties:
                periodStart:
                  type: string
                apiEnabled:
                  type: boolean
                monthlyUnits:
                  type: integer
                validationUnitsUsed:
                  type: integer
                validationUnitsRemaining:
                  type: integer
                requestsUsed:
                  type: integer
            policy:
              type: object
              description: |
                Plan-level features, limits, and usage rolled up across the
                user's account (every API key + dashboard activity counts).
                Use this to render gates / "X of Y left this month" UI on
                top of the API. The per-API-key picture lives in `quota`.
              properties:
                planTier:
                  type: string
                  example: pro
                features:
                  type: object
                  description: Plan feature flags. Forward-compatible — add cases as new flags ship.
                  properties:
                    verifyListEnabled: { type: boolean }
                    verifyEmailEnabled: { type: boolean }
                    basicScanEnabled: { type: boolean }
                    deepScanEnabled: { type: boolean }
                    apiAccessEnabled: { type: boolean }
                    exportsEnabled: { type: boolean }
                  additionalProperties:
                    type: boolean
                limits:
                  type: object
                  description: Plan limits. Numeric ceilings; 0 means unavailable.
                  properties:
                    singleChecksPerMonth: { type: integer }
                    listRowsPerMonth: { type: integer }
                    deepScanSingleChecksPerMonth: { type: integer }
                    deepScanRowsPerMonth: { type: integer }
                    maxRowsPerUpload: { type: integer }
                    maxActiveJobs: { type: integer }
                    apiRequestsPerMinute: { type: integer }
                    apiKeysAllowed: { type: integer }
                    apiValidationUnitsPerMonth: { type: integer }
                  additionalProperties:
                    type: integer
                usage:
                  type: object
                  description: Counters for the current billing period.
                  properties:
                    singleChecksUsed: { type: integer }
                    deepSingleChecksUsed: { type: integer }
                    listRowsUsed: { type: integer }
                    deepListRowsUsed: { type: integer }
                remaining:
                  type: object
                  description: Convenience deltas — `limit - used`, floored at 0.
                  properties:
                    singleChecksRemaining: { type: integer }
                    deepSingleChecksRemaining: { type: integer }
                    listRowsRemaining: { type: integer }
                    deepListRowsRemaining: { type: integer }
                period:
                  type: object
                  description: ISO-8601 boundaries for the billing period these counters apply to.
                  properties:
                    periodStart:
                      type: string
                      format: date-time
                    periodEnd:
                      type: string
                      format: date-time
            limits:
              type: object
              description: Per-API-key limits derived from the user's plan. Mirrors a subset of `policy.limits`.
              properties:
                maxRowsPerUpload:
                  type: integer
                maxActiveJobs:
                  type: integer
                rateLimitPerMinute:
                  type: integer
                maxApiKeys:
                  type: integer
  responses:
    Unauthorized:
      description: Authentication failed
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    Forbidden:
      description: Scope or access denied
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    RateLimited:
      description: Too many requests
      headers:
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/XRateLimitRemaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/XRateLimitReset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    PlanOrQuotaError:
      description: Plan upgrade or quota required
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    ValidationError:
      description: Invalid request body or file
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
paths:
  /me:
    get:
      tags: [Account]
      summary: Get authenticated API consumer details and quota snapshot
      description: >
        Returns the current effective plan, resolved limits, and remaining quota for the authenticated key.
        Read this endpoint before large jobs or after billing-related changes to confirm what the account can do right now.
      responses:
        "200":
          description: Consumer profile and current usage
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MeResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "429":
          $ref: '#/components/responses/RateLimited'
  /email-validations:
    post:
      tags: [Validations]
      summary: Validate a single email address
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ValidationCreateRequest'
      responses:
        "200":
          description: Validation accepted
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationCreateResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "402":
          $ref: '#/components/responses/PlanOrQuotaError'
        "409":
          description: Idempotency conflict or deep scan unavailable
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        "422":
          $ref: '#/components/responses/ValidationError'
        "429":
          $ref: '#/components/responses/RateLimited'
  /email-validations/{id}:
    get:
      tags: [Validations]
      summary: Poll a prior single-email validation
      parameters:
        - $ref: '#/components/parameters/ValidationId'
      responses:
        "200":
          description: Validation record
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationCreateResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimited'
  /jobs:
    get:
      tags: [Jobs]
      summary: List jobs for the authenticated user
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/LimitJobs'
        - in: query
          name: status
          schema:
            $ref: '#/components/schemas/JobStatus'
        - in: query
          name: q
          schema:
            type: string
      responses:
        "200":
          description: Paginated jobs
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PagedResponse'
                  - properties:
                      data:
                        items:
                          $ref: '#/components/schemas/Job'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "402":
          $ref: '#/components/responses/PlanOrQuotaError'
        "429":
          $ref: '#/components/responses/RateLimited'
    post:
      tags: [Jobs]
      summary: Create a bulk validation job
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file:
                  type: string
                  format: binary
                scanMode:
                  $ref: '#/components/schemas/ScanMode'
          application/json:
            schema:
              $ref: '#/components/schemas/JobCreateRequest'
      responses:
        "202":
          description: Job queued
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobCreateResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "402":
          $ref: '#/components/responses/PlanOrQuotaError'
        "409":
          description: Idempotency conflict or deep scan unavailable
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        "413":
          description: File or row limit exceeded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        "422":
          $ref: '#/components/responses/ValidationError'
        "429":
          $ref: '#/components/responses/RateLimited'
  /jobs/{jobId}:
    get:
      tags: [Jobs]
      summary: Get one job
      parameters:
        - $ref: '#/components/parameters/JobId'
      responses:
        "200":
          description: Job detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Job'
        "304":
          description: Not modified
        "401":
          $ref: '#/components/responses/Unauthorized'
        "404":
          $ref: '#/components/responses/NotFound'
  /jobs/{jobId}/cancel:
    post:
      tags: [Jobs]
      summary: Request job cancellation
      parameters:
        - $ref: '#/components/parameters/JobId'
      responses:
        "200":
          description: Job cancellation requested
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Job'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
  /jobs/{jobId}/results:
    get:
      tags: [Jobs]
      summary: List paginated results for a job
      parameters:
        - $ref: '#/components/parameters/JobId'
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/LimitResults'
        - in: query
          name: status
          schema:
            $ref: '#/components/schemas/FinalStatus'
        - in: query
          name: reason
          schema:
            type: string
        - in: query
          name: q
          schema:
            type: string
        - in: query
          name: sortBy
          schema:
            type: string
        - in: query
          name: sortDir
          schema:
            type: string
            enum: [asc, desc]
      responses:
        "200":
          description: Paginated results
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PagedResponse'
                  - properties:
                      data:
                        items:
                          $ref: '#/components/schemas/JobResult'
        "304":
          description: Not modified
        "401":
          $ref: '#/components/responses/Unauthorized'
        "404":
          $ref: '#/components/responses/NotFound'
  /jobs/{jobId}/exports:
    post:
      tags: [Exports]
      summary: Queue an export for a job
      parameters:
        - $ref: '#/components/parameters/JobId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ExportCreateRequest'
      responses:
        "202":
          description: Export queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Export'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "409":
          description: Export blocked while deep scan is still refining
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
  /jobs/{jobId}/exports/{exportId}:
    get:
      tags: [Exports]
      summary: Get one export record
      parameters:
        - $ref: '#/components/parameters/JobId'
        - $ref: '#/components/parameters/ExportId'
      responses:
        "200":
          description: Export detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Export'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "404":
          $ref: '#/components/responses/NotFound'
  /jobs/{jobId}/exports/{exportId}/download:
    get:
      tags: [Exports]
      summary: Download a completed export file
      parameters:
        - $ref: '#/components/parameters/JobId'
        - $ref: '#/components/parameters/ExportId'
      responses:
        "200":
          description: Export file download
          content:
            text/csv: {}
            application/json: {}
        "401":
          $ref: '#/components/responses/Unauthorized'
        "404":
          $ref: '#/components/responses/NotFound'
  /jobs/{jobId}/export:
    get:
      tags: [Exports]
      summary: Download an existing export or queue a new one
      parameters:
        - $ref: '#/components/parameters/JobId'
        - in: query
          name: format
          schema:
            type: string
            enum: [csv, json]
      responses:
        "200":
          description: Existing export download
        "202":
          description: Export queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Export'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "404":
          $ref: '#/components/responses/NotFound'
        "409":
          description: Export blocked while deep scan is still refining
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
