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 credits 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.

        ### Billing-aware codes on `402 Payment Required`
        When a request hits a plan-gated route, the server resolves the
        user's current account state (active, paused, past-due, expired,
        refund-revoked, admin-locked) and returns a state-specific code
        so clients can render the right recovery action.

        - `plan_upgrade_required` — the user is on a plan that doesn't
          include the requested feature. Action: upgrade.
        - `billing_subscription_paused` — Pro subscription paused in
          Paddle. Action: resume in the Paddle portal.
        - `billing_subscription_past_due` — Pro subscription, payment
          needs attention (typically a failed renewal). Action: update
          payment method.
        - `billing_subscription_expired` — Pro access has ended (cancel
          fully elapsed, or refund revoked Pro). Action: resubscribe.
        - `billing_admin_locked` — an admin override is currently
          managing this user's plan. Action: contact support.

        Source: `getPlanGateErrorForUser` in
        `src/services/accountDisplayStateService.js`.
      enum:
        - unauthorized
        - forbidden
        - not_found
        - validation_error
        - rate_limited
        - quota_exceeded
        - plan_upgrade_required
        - billing_subscription_paused
        - billing_subscription_past_due
        - billing_subscription_expired
        - billing_admin_locked
        - 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]
    SmtpEnrichmentStatus:
      type: string
      description: |
        Lifecycle of the deep-scan SMTP enrichment phase.

        - `not_applicable` — Quick scan, or no addresses were eligible
          for SMTP probing.
        - `pending` — Deep scan queued, no probes have run yet.
        - `processing` — Probes are running. Per-result `smtpStatus`
          fields are progressively populated.
        - `completed` — All eligible addresses have been probed.
        - `failed` — Enrichment terminated with errors. Inspect job
          logs for the cause.
      enum: [not_applicable, pending, processing, completed, failed]
    Evidence:
      type: object
      description: |
        One link in the per-signal evidence chain that the validation
        engine emits for a single email. The full chain is returned on
        `ValidationResult.evidence` and exposes WHY a final verdict was
        reached, in the order signals fired (syntax → mx → provider →
        policy → tls → smtp). Use this to render human-readable
        explanations or to feed an LLM that needs to reason about a
        verdict.
      required: [signal, finding, tone, confidence, text]
      properties:
        signal:
          type: string
          description: Which probe layer emitted this row.
          enum: [syntax, mx, provider, policy, tls, smtp]
        finding:
          type: string
          description: |
            Stable machine-readable finding for this signal (e.g.
            `syntax_valid`, `mx_found`, `provider_matched`,
            `smtp_accept`, `smtp_reject`, `tls_present`). The full
            vocabulary is engine-internal and may grow over time —
            treat unknown values as opaque labels.
        tone:
          type: string
          description: |
            UI hint for rendering. `good` = positive corroborating
            signal; `warn` = caveat (e.g. catch-all, throttled);
            `bad` = negative signal (e.g. SMTP reject); `unknown` =
            indeterminate (e.g. SMTP timeout).
          enum: [good, warn, bad, unknown]
        confidence:
          type: number
          format: float
          minimum: 0
          maximum: 1
          description: |
            Confidence in this single signal, 0..1. Not the final
            verdict's confidence — that's on the parent
            `ValidationResult.confidence`.
        text:
          type: string
          description: One-sentence human-readable description of the finding.
    ValidationResult:
      type: object
      description: |
        A single-email validation record. Returned by
        `POST /v1/email-validations` and `GET /v1/email-validations/{id}`.
      properties:
        id:
          type: integer
        email:
          type: string
        scanMode:
          $ref: '#/components/schemas/ScanMode'
        isTest:
          type: boolean
          description: True when the validation was issued by a test-mode API key. Test-mode validations do not consume credits.
        enrichmentStatus:
          allOf:
            - $ref: '#/components/schemas/SmtpEnrichmentStatus'
          nullable: true
          description: |
            Same lifecycle as `Job.smtpEnrichment.status` — null when
            the row pre-dates the enrichment phase or the field was not
            populated by the storage layer.
        enrichmentStartedAt:
          type: string
          format: date-time
          nullable: true
        enrichmentCompletedAt:
          type: string
          format: date-time
          nullable: true
        finalStatus:
          $ref: '#/components/schemas/FinalStatus'
        explanation:
          type: string
          description: Plain-English summary of the verdict.
        score:
          type: integer
          minimum: 0
          maximum: 100
        reason:
          type: string
          nullable: true
          description: |
            Stable machine-readable label for the dominant reason behind
            the verdict (e.g. `mx`, `typo`, `disposable`, `checks_passed`,
            `smtp_confirmed`, `smtp_signals_disagree`, `role_based`,
            `m365_directory_confirmed`, `m365_directory_rejected`,
            `m365_mailbox_not_authoritatively_confirmed`,
            `m365_directory_unconfirmed`).
            `smtp_confirmed` / `smtp_rejected` indicate the SMTP probe
            directly corroborated the verdict. `m365_directory_confirmed`
            / `m365_directory_rejected` indicate the M365 directory
            cascade was the sole supporting signal (SMTP probe gave no
            usable signal — rate-limited, timed out, or non-decisive).
            `m365_mailbox_not_authoritatively_confirmed` /
            `m365_directory_unconfirmed` are emitted when the M365
            authority classifier deems the cascade non-authoritative
            for mailbox storage (hybrid Exchange, federated tenants,
            cache-served verdicts without preserved posture) — the
            verdict is downgraded to `unknown` to avoid the
            hybrid-Exchange false-positive class.
            Flags can additionally include
            `m365_gateway_acceptance_untrusted` and
            `m365_directory_not_mailbox_authoritative` on those
            same downgrade paths.
            The field is free-form `string`; this list is illustrative,
            not exhaustive.
        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
          description: True while enrichment is still pending/processing — the verdict can still change.
        reasonFlags:
          type: array
          items:
            type: string
          description: |
            Engine signals that contributed to the verdict (e.g.
            `provider_microsoft`, `m365_http_enum`, `mx_missing`,
            `disposable_domain`). Multiple flags may be present; use
            `reason` for the dominant one.
        evidence:
          type: array
          nullable: true
          description: |
            Ordered per-signal evidence chain (added in PR #165). `null`
            for legacy-engine rows or pre-#165 records. Clients MUST
            handle the null case gracefully.
          items:
            $ref: '#/components/schemas/Evidence'
        confidence:
          type: number
          format: float
          minimum: 0
          maximum: 1
          nullable: true
          description: |
            Engine confidence in the final verdict, 0..1 (added in
            PR #165). Distinct from per-evidence-row `confidence`.
            `null` for legacy-engine rows.
        explain:
          type: string
          nullable: true
          description: |
            One-sentence plain-English explanation of why this verdict
            was reached (added in PR #165). `null` for legacy-engine
            rows. Prefer this over re-deriving an explanation from
            `reason` + `reasonFlags` when present.
        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:
          allOf:
            - $ref: '#/components/schemas/JobSmtpEnrichment'
          nullable: true
          description: |
            Lifecycle + progress of the deep-scan SMTP enrichment phase.

            Present on every job that has reached the validation phase
            (both quick and deep scans). For quick-scan jobs the object
            is populated with `status: not_applicable` and zero counts —
            it is **NOT** `null`. The presenter only returns `null`
            when the underlying job row has no `smtpEnrichmentStatus`
            field at all, which happens for pre-feature legacy rows and
            for a brief window during job creation before the processor
            has set the initial status.

            For deep-scan jobs, `status` walks
            `pending → processing → completed` (or `failed`), and the
            counts populate as probes complete. Use `inProgress` as the
            UI/agent signal for "should I poll again?"
        confidence:
          allOf:
            - $ref: '#/components/schemas/JobConfidence'
          nullable: true
          description: |
            Server-computed verdict-confidence summary for the whole
            job. `null` until the job reaches `status: completed`, then
            populated with one of three levels based on the share of
            unknowns vs. confirmed results.
    JobSmtpEnrichment:
      type: object
      description: |
        Per-job snapshot of the deep-scan SMTP enrichment lifecycle.
        Counts are cumulative across the job; `progressPct` is derived
        from `processed / (pending + processed)`.
      required: [status, pending, processed, enriched, skipped, failed, progressPct, inProgress]
      properties:
        status:
          $ref: '#/components/schemas/SmtpEnrichmentStatus'
        pending:
          type: integer
          minimum: 0
          description: Addresses queued for SMTP probing but not yet attempted.
        processed:
          type: integer
          minimum: 0
          description: Addresses that have completed the SMTP probe step (success, skip, or failure).
        enriched:
          type: integer
          minimum: 0
          description: Addresses whose verdict was updated by SMTP signals.
        skipped:
          type: integer
          minimum: 0
          description: Addresses skipped because SMTP probing was not applicable (e.g. catch-all domain detected).
        failed:
          type: integer
          minimum: 0
          description: Addresses whose probe terminated with an error and could not be enriched.
        progressPct:
          type: integer
          minimum: 0
          maximum: 100
          description: Convenience field, `Math.round((processed / (pending + processed)) * 100)`, capped at 100.
        startedAt:
          type: string
          format: date-time
          nullable: true
        completedAt:
          type: string
          format: date-time
          nullable: true
        inProgress:
          type: boolean
          description: True while `status` is `pending` or `processing`. UI convenience for spinner state.
    JobConfidence:
      type: object
      description: |
        Server-computed verdict-confidence summary derived from the
        ratio of unknowns to confirmed results. Three levels:

        - `high` — unknown ratio < 5% (or zero).
        - `medium` — unknown ratio in [5%, 20%), or enrichment still
          in flight on a completed job.
        - `low` — unknown ratio >= 20%.

        `message` is a one-sentence plain-English summary suitable for
        rendering directly in the UI.
      required: [level, message]
      properties:
        level:
          type: string
          enum: [high, medium, low]
        message:
          type: string
    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: |
                Validation-credit balance for the current calendar month
                (UTC). Use `monthlyValidationCredits` /
                `monthlyValidationCreditsUsed` /
                `monthlyValidationCreditsRemaining` — those are the
                canonical fields, sourced from the unified credit pool
                that Verify Email, Verify List, and the API all share.
                The `monthlyUnits` / `validationUnitsUsed` /
                `validationUnitsRemaining` fields are deprecated aliases
                kept for back-compat; they carry the same values and
                will be removed in a future major version.

                Credit period vs Paddle billing period: the credit
                counter resets at 00:00 UTC on the 1st of every
                calendar month, regardless of when the user's Paddle
                subscription was first activated. The two clocks are
                decoupled by design. See
                `docs/current/credit-billing-model.md` for the full
                state-transition matrix (upgrade / cancel / cross-
                period refund / test-mode skip).
              properties:
                periodStart:
                  type: string
                  format: date
                  description: First day of the current calendar month (UTC), formatted YYYY-MM-DD.
                apiEnabled:
                  type: boolean
                monthlyValidationCredits:
                  type: integer
                  description: Canonical monthly credit ceiling for the user's plan.
                monthlyValidationCreditsUsed:
                  type: integer
                  nullable: true
                  description: Canonical credit consumption this calendar month (UTC) across all channels (Verify Email, Verify List, API). `null` if the credit counter could not be loaded — render a degraded state rather than treating null as 0.
                monthlyValidationCreditsRemaining:
                  type: integer
                  nullable: true
                  description: Canonical credits remaining this calendar month (UTC). `null` if the credit counter could not be loaded.
                requestsUsed:
                  type: integer
                  description: Per-API-key request count this calendar month (UTC). Not credits — counts every API call including ones that did not validate an email.
                monthlyAiAnalystCredits:
                  type: integer
                  description: |
                    Monthly AI Analyst credit ceiling for this user's plan. Read-only — exposed so
                    API consumers can surface "X AI Analyst credits remaining" in their own dashboards.
                    The AI analysis action (POST /workspace/analyse-with-ai) is workspace-only and
                    cannot be triggered via the API.
                aiAnalystCreditsUsed:
                  type: integer
                  description: AI Analyst credits consumed this calendar month (UTC).
                aiAnalystCreditsRemaining:
                  type: integer
                  description: AI Analyst credits remaining this calendar month (UTC). Floored at 0.
                monthlyUnits:
                  type: integer
                  deprecated: true
                  description: |
                    Deprecated alias of `monthlyValidationCredits`. Will be removed in a future major version.

                    Note: the JS-side entitlement alias was dropped internally in PR #230 — every internal consumer now reads `monthlyValidationCredits`. The public API field here remains, sourced directly from `snapshot.monthlyValidationCredits` so external integrations keep working until we cut a major version.
                validationUnitsUsed:
                  type: integer
                  nullable: true
                  deprecated: true
                  description: Deprecated alias of `monthlyValidationCreditsUsed`. Pre-cleanup this was an API-only counter that combined inconsistently with the unified credit ceiling; it now reflects the unified credit consumption. Will be removed in a future major version.
                validationUnitsRemaining:
                  type: integer
                  nullable: true
                  deprecated: true
                  description: Deprecated alias of `monthlyValidationCreditsRemaining`. Pre-cleanup the math was broken (unified ceiling minus API-only used) and overstated remaining. Now correctly reflects unified remaining. Will be removed in a future major version.
            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. Verify Email, Verify List, and the API all
                    draw from a single monthly credit pool — `monthlyValidationCredits`.
                    `monthlyAiAnalystCredits` is a separate pool for the AI Analyst feature
                    (workspace-only action; balance is read-only here).
                    The remaining fields are operational caps (per-job and
                    per-account safety limits), not credit quotas.
                  required:
                    - monthlyValidationCredits
                  properties:
                    monthlyValidationCredits: { type: integer }
                    monthlyAiAnalystCredits: { type: integer, description: "AI Analyst credit ceiling. The AI analysis action is workspace-only — this is a read-only balance field." }
                    maxRowsPerUpload: { type: integer }
                    maxActiveJobs: { type: integer }
                    apiRequestsPerMinute: { type: integer }
                    apiKeysAllowed: { type: integer }
                  additionalProperties:
                    type: integer
                usage:
                  type: object
                  description: Counters for the current calendar month (UTC). Not the Paddle billing period.
                  required:
                    - monthlyValidationCreditsUsed
                  properties:
                    monthlyValidationCreditsUsed: { type: integer }
                    aiAnalystCreditsUsed: { type: integer, description: "AI Analyst credits consumed this month." }
                remaining:
                  type: object
                  description: Convenience delta — `monthlyValidationCredits - monthlyValidationCreditsUsed`, floored at 0. Also includes AI Analyst remaining.
                  required:
                    - monthlyValidationCreditsRemaining
                  properties:
                    monthlyValidationCreditsRemaining: { type: integer }
                    aiAnalystCreditsRemaining: { type: integer, description: "AI Analyst credits remaining this month, floored at 0." }
                period:
                  type: object
                  description: |
                    Calendar-month (UTC) boundaries these counters apply to.
                    `periodStart` is the 1st of the current month; `periodEnd`
                    is the last day of the same month. Both are formatted
                    YYYY-MM-DD (a `date`, NOT a `date-time` — these are
                    inclusive calendar-day labels, not instants). NOT the
                    Paddle billing period — see `docs/current/credit-billing-model.md`.
                  properties:
                    periodStart:
                      type: string
                      format: date
                    periodEnd:
                      type: string
                      format: date
            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, quota exhausted, or billing-state recovery required.
        The `error.code` is one of: `plan_upgrade_required`,
        `billing_subscription_paused`, `billing_subscription_past_due`,
        `billing_subscription_expired`, `billing_admin_locked`,
        or `quota_exceeded`. See `ErrorCode` for the action each code
        implies.
      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. The
            `error.code` is `deep_scan_unavailable` (reused — the user
            IS on a plan that includes deep scan; they're being asked
            to wait until enrichment finishes so the export reflects
            the final verdicts). Poll the job until
            `smtpEnrichment.inProgress` is `false`, then retry.
          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. The
            `error.code` is `deep_scan_unavailable` (reused — the user
            IS on a plan that includes deep scan; they're being asked
            to wait until enrichment finishes so the export reflects
            the final verdicts). Poll the job until
            `smtpEnrichment.inProgress` is `false`, then retry.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
