openapi: 3.1.0
info:
  title: EmailValidator Public API
  version: "1"
  description: |
    REST API for programmatic email validation. All endpoints are under `/api/v1`
    and require an API key.

    **Authentication** — supply your key in the `X-Api-Key` request header (preferred)
    or as the `?api_key=` query parameter (useful for quick tests; avoid in production
    logs where the URL may be recorded).

    **Rate limiting** — 60 requests per minute per account. Every key on an account draws
    from the same budget, so adding keys does not add throughput. Exceeding the limit returns `429`.
    Every budgeted response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and
    `X-RateLimit-Reset`, and a `429` adds `Retry-After`. `GET /api/v1/events/stream` is
    deliberately outside the budget and carries none of them.

    **Request ids** — every `/api/v1` response carries `X-Request-Id`, and every error body
    repeats it as `request_id`. Quote it in a support request.

    **Idempotency** — the two credit-spending POSTs accept an `Idempotency-Key` header. A
    retry with the same key replays the original response (`Idempotency-Replayed: true`)
    instead of charging twice.

    **Webhooks** — a delivery signed with `X-Signature` is retried up to 22 times over about
    3.1 days (30s doubling to a 6h ceiling, each delay jittered ±20%). When the budget is
    exhausted the delivery is marked `Abandoned` and an in-app notification is raised.

    **Test mode** — API keys beginning with `ev_test_` return instant, canned results
    at zero credit cost. The outcome depends on the local part of the submitted address
    (e.g. `valid@`, `invalid@`, `catch_all@`, `disposable@`, `role@`, `unknown@`).
servers:
  - url: https://api.emailvalidator.ai

security:
  - apiKey: []

components:
  securitySchemes:
    apiKey:
      type: apiKey
      in: header
      name: X-Api-Key
      description: |
        API key issued from your account dashboard. Prefix `ev_` for production keys,
        `ev_test_` for test-mode keys.

        **Alternative** — pass the key as `?api_key=<key>` in the query string when
        setting a header is inconvenient (e.g. browser EventSource). Avoid this in
        production where URLs appear in server logs.

  headers:
    X-Request-Id:
      description: |
        Returned on **every** `/api/v1` response, success or failure. The correlation id of this
        request — the same value an error body carries as `request_id`, and the id of the row in the
        per-request log shown in the member portal (Member → API → Logs, 7-day retention). Quote it
        in a support request and the exact call can be found.
      schema:
        type: string
      example: "0HNCV1TQ3PLRK:00000003"

    X-RateLimit-Limit:
      description: |
        Requests allowed per window (one minute). Present on every rate-limited `/api/v1` response.

        On a `429` this reports the ceiling that **actually rejected** the caller, which can be the
        coarse pre-authentication guard's own ceiling rather than the per-account one — so a `429`
        may show a different number from the caller's `200`s. Absent from a `401` (no account
        resolved) and from `GET /api/v1/events/stream` (outside the budget).
      schema:
        type: integer
        minimum: 1
      example: 60

    X-RateLimit-Remaining:
      description: |
        Requests left in the current window, already accounting for the response carrying the
        header. `0` on a rejection. Throttle on this rather than waiting to be rejected.
      schema:
        type: integer
        minimum: 0
      example: 57

    X-RateLimit-Reset:
      description: |
        An **absolute Unix timestamp in seconds** — the instant the current window ends. Not a delay:
        it is identical on every response within the same window, so a client that queues work does
        not need to know when it read the header.
      schema:
        type: integer
      example: 1750000080

    Retry-After:
      description: |
        Whole seconds to wait before retrying, per RFC 9110 — the remaining time until
        `X-RateLimit-Reset`, derived from the same instant so the two cannot disagree. Sent on a
        `429` only, and never `0`: a sub-second remainder still means "wait".
      schema:
        type: integer
        minimum: 1
      example: 12

    Idempotency-Replayed:
      description: |
        Sent as `true` when the response is a **replay** of an earlier request that carried the same
        `Idempotency-Key` — the stored status and body, byte for byte, with no new work done and no
        second charge. Absent when the request was executed for the first time.
      schema:
        type: string
        enum:
          - "true"
      example: "true"

    X-Signature:
      description: |
        Sent by EmailValidator on **every outbound webhook delivery** (it is a header on the request
        we POST to your `callback_url`, not on any API response).

        Format:

        ```
        X-Signature: t=<unixSeconds>,v1=<lowercaseHex>[,v1=<lowercaseHex>]
        ```

        To verify, recompute `HMAC-SHA256(secret, "{t}.{rawRequestBody}")` as lowercase hex and
        compare it in constant time against **each** `v1` value — a match on any one of them is
        valid. Sign the raw bytes you received, before any JSON re-serialisation.

        - **`t`** is the Unix timestamp (seconds) the delivery was signed at, and is **inside** the
          MAC. Reject a delivery whose `t` is more than **5 minutes** from your clock: that check is
          what stops a captured delivery from being replayed later.
        - **More than one `v1`** appears while a signing secret is mid-rotation — one signature per
          active secret, current first — so a delivery verifies against either the new or the
          previous secret until the overlap window closes.

        The secret is your **account signing secret** (`whsec_…`), visible and rotatable in the API
        section of your dashboard. A per-request `callback_secret`, when you supply one, overrides it
        for that job's delivery and signs alone.
      schema:
        type: string
        pattern: '^t=\d+(,v1=[0-9a-f]{64})+$'
      example: t=1750000000,v1=aaaaaaaabbbbbbbbccccccccddddddddeeeeeeeeffffffff0000000011111111

    X-Ev-Event:
      description: |
        Sent by EmailValidator on **every outbound webhook delivery**, alongside `X-Signature`. The
        event name — the same value as the envelope's `event` field — so a handler can route before
        parsing the body.

        `evctl listen` and `evctl trigger` send the identical header locally, so a handler developed
        against the CLI sees exactly what production sends.
      schema:
        type: string
        enum:
          - verify.completed
          - batch.completed
          - test
      example: verify.completed

    X-Ev-Delivery:
      description: |
        Sent by EmailValidator on **every outbound webhook delivery**, alongside `X-Signature`. The
        1-based attempt number of THIS delivery: `1` on the first attempt, incrementing on each
        retry. Useful for logging and for spotting that you are being re-delivered an event you
        already processed (the envelope `id` is the idempotency key — it does **not** change
        between attempts).
      schema:
        type: integer
        minimum: 1
      example: 1

  schemas:
    VerifyRequest:
      type: object
      required:
        - email
      properties:
        email:
          type: string
          description: Email address to validate.
          example: alice@example.com
        timeout:
          type: integer
          minimum: 3
          maximum: 60
          default: 30
          description: |
            Seconds to wait for a synchronous result (3–60). When the validation
            completes within this window the response is `200 OK` with the full
            result. When the timeout elapses first the response is `202 Accepted`
            with a `result_url` to poll.
          example: 15
        callback_url:
          type: string
          format: uri
          description: |
            HTTPS URL to receive the result as a signed webhook. When supplied the
            request returns `202 Accepted` immediately and the result is delivered
            asynchronously. The delivery carries an `X-Signature` header signed with your
            account signing secret (`whsec_…`, from your dashboard) — pin that one secret
            rather than storing the per-job `callback_secret` of every request.
          example: https://yourapp.example.com/hooks/email

    Verification:
      type: object
      description: |
        A single-email verification. This ONE object is what every representation of the resource
        returns: the synchronous `200`, the `202` that accepts an async or timed-out request, both
        states of `GET /api/v1/verify/{id}`, every row of `GET /api/v1/verify`, and the
        `verify.completed` webhook payload.

        **Two axes.** `status` is the LIFECYCLE (`pending` or `complete`) and never carries a verdict.
        The verdict is `result.outcome`, in the same vocabulary the CSV export and the dashboard use.
        `result` and `recovery` are both null while pending, because a check that has not finished has
        reached no conclusion — the terminal `unknown` is a conclusion, not an absence of one.

        **Test and live are separate namespaces.** `livemode` says which one this verification belongs
        to, and a key only ever sees resources of its own mode: a sandbox (`ev_test_`) key cannot list
        or fetch a live verification, and a live key never sees a canned sandbox one.
      properties:
        object:
          type: string
          enum:
            - verification
          description: Always `verification` — the discriminator that marks this resource type.
          example: verification
        id:
          type: string
          format: uuid
          description: >-
            The verification's ID, present on every representation. Pass it to
            `GET /api/v1/verify/{id}`.
          example: 3fa85f64-5717-4562-b3fc-2c963f66afa6
        created_at:
          type: string
          format: date-time
          description: >-
            UTC timestamp when the verification was accepted. It does not change when the
            verification completes, which is what makes the list's cursor stable while checks finish.
          example: "2025-06-10T14:02:09.180Z"
        livemode:
          type: boolean
          description: >-
            `true` when the verification was submitted with a LIVE key; `false` when it came from an
            `ev_test_` sandbox key and the verdict is therefore a canned test result rather than a real
            probe. Test and live are separate namespaces — a key only ever sees resources of its own
            mode — so this is constant for any one key, and it is what makes a stored response, a log
            line or a webhook body self-describing after the fact.
          example: true
        status:
          $ref: '#/components/schemas/VerificationStatus'
        email:
          type: string
          description: The address as submitted. A recovery never replaces it.
          example: alice@example.com
        result:
          $ref: '#/components/schemas/VerificationResult'
          nullable: true
          description: The verdict. `null` while `status` is `pending`.
        recovery:
          $ref: '#/components/schemas/VerificationRecovery'
          nullable: true
          description: >-
            A probe-confirmed alternative address, when contact recovery found one. `null` while
            pending, and `null` when no recovery was surfaced.
        completed_at:
          type: string
          format: date-time
          nullable: true
          description: UTC timestamp when the verification finished. `null` while pending.
          example: "2025-06-10T14:02:11.432Z"
        credits_remaining:
          type: integer
          nullable: true
          description: >-
            Credit balance after this request. `null` for system/demo accounts, and `null` on list
            rows, webhook payloads and SSE frames — a balance is a per-request figure, so read it from
            `GET /api/v1/credits`.
          example: 4820
        result_url:
          type: string
          description: >-
            Where to fetch the finished verification. Present only while `status` is `pending`; once
            complete you are already holding what it would point at.
          example: /api/v1/verify/3fa85f64-5717-4562-b3fc-2c963f66afa6
        callback_secret:
          type: string
          description: >-
            A per-verification override of the account signing secret, revealed EXACTLY ONCE — on the
            `202` that accepts a request carrying a `callback_url`. It is deliberately absent from
            `GET /api/v1/verify/{id}`, from list rows and from the webhook payload. Verifying with
            the account secret (`whsec_…`) works without it.


            Shape: `cs_` + 64 hex characters for a single verification (`bv_` + 64 hex for a batch
            job). It is NOT `whsec_`-shaped — that prefix belongs to the ACCOUNT signing secret, a
            different value with a different lifetime.
          example: cs_9f2a4c6e...

    VerificationResult:
      type: object
      description: The verdict on the submitted address, and the signals behind it.
      properties:
        outcome:
          $ref: '#/components/schemas/VerifyStatus'
        sub_status:
          $ref: '#/components/schemas/VerifySubStatus'
        risk:
          type: string
          enum: [none, low, medium, high]
          description: >-
            Deliverability risk, assessed at PROBE TIME from everything the check observed — not
            derived from `outcome`, and not derivable from the other fields in this object. It reads
            the SMTP conversation's real status, the domain's mail-server state, DNS transience and
            the address's own syntax, several of which are not reported anywhere else on this
            response. So it is a genuinely independent signal, and it will sometimes disagree with
            what `outcome` and `details` on their own would suggest.


            Broadly: `high` for a definitive rejection, a disposable domain, a domain with no mail
            server, or a structurally suspect address (a quoted or commented local part); `medium`
            for an accept-all domain, a role address, a non-ASCII local part, or a mailbox whose
            probe was DEFERRED by the server (a `4xx` temporary failure — a real, common case whose
            `outcome` is `unknown`); `low` for a free-mail domain or an address we could not verify
            conclusively; `none` for a clean, confirmed mailbox with no such signal. Treat the four
            values as an ordered scale rather than as a formula over the other fields.


            Returned for both live and test-mode keys (a test-mode key returns the canned risk the
            address's local part maps to). OMITTED — not reported as `none` — whenever the check
            carries no recorded assessment. This rule is WIDER than `free_email`'s and `details`':
            those two are withheld only when the stored probe detail cannot be read, while `risk` is
            also absent on a healthy row that has no stored detail at all (a check completed before
            this became a recorded value, until it is backfilled), and while a check is still
            pending. An absent `risk` means unknown, never `none`.
          example: none
        free_email:
          type: boolean
          description: >-
            Whether the domain is a known free-email provider. Derived from the stored probe detail, so
            it is OMITTED — not reported as `false` — on the rare row whose stored detail cannot be
            read; see `details`.
          example: false
        account:
          type: string
          description: Local part of the email address (before `@`).
          example: alice
        domain:
          type: string
          description: Domain part of the email address (after `@`).
          example: example.com
        details:
          $ref: '#/components/schemas/VerifyDetails'
          description: >-
            Low-level DNS/SMTP signal. OMITTED on the rare row whose stored probe detail exists but
            cannot be read — a server-side data fault. The verdict in `outcome` comes from a separate,
            intact field and stays trustworthy, so the detail is withheld rather than reported as a row
            of `false`s that would read as a confident all-clear. A row that simply has no stored detail
            is not that case and still returns this object with its default values.

    VerificationRecovery:
      type: object
      description: |
        A recovered address: an alternative to the submitted one that our probes CONFIRMED, when the
        submitted address itself turned out to be dead. It is also the reason an `unknown` or
        `invalid` verification can still be billed — a definitive outcome **or** a recovery is what
        you pay for, so this object is what explains the charge.

        Nothing is surfaced here that a probe did not resolve, and nothing ambiguous is surfaced at
        all: if several different addresses came back deliverable, or several different domains came
        back catch-all, no recovery is returned rather than a guess between them. There is
        deliberately **no confidence field** — `outcome` below is the only thing you can act on.
      properties:
        email:
          type: string
          description: The recovered address. Never a substitute for `email` — that is what you sent.
          example: alice@gmail.com
        outcome:
          type: string
          enum:
            - deliverable
            - catch-all
          description: >-
            `deliverable` when a probe confirmed a mailbox at the recovered address; `catch-all` when
            the fix landed on a domain that accepts everything. Drawn from the same vocabulary as
            `result.outcome` and the CSV's `Outcome` column, and identical to the CSV's
            `RecoveredOutcome` cell for the same row.
          example: deliverable

    VerifyDetails:
      type: object
      description: Low-level signal from DNS and SMTP probing.
      properties:
        role:
          type: boolean
          description: Whether the local part is a role alias (e.g. `admin`, `support`, `noreply`).
          example: false
        disposable:
          type: boolean
          description: Whether the domain belongs to a known disposable/temporary email provider.
          example: false
        catch_all:
          type: boolean
          description: Whether the domain is configured to accept all addresses.
          example: false
        mx_found:
          type: boolean
          description: Whether at least one MX record was found for the domain.
          example: true
        mx_record:
          type: string
          nullable: true
          description: The highest-priority MX hostname, or null when no MX records exist.
          example: mail.example.com
        smtp_provider:
          type: string
          nullable: true
          description: Detected SMTP provider name (e.g. `Google`, `Microsoft`), or null when unknown.
          example: Google
        smtp_code:
          type: integer
          nullable: true
          description: SMTP response code from the final RCPT TO probe, or null when no SMTP probe was attempted.
          example: 250

    VerificationStatus:
      type: string
      enum:
        - pending
        - complete
      description: |
        The verification's LIFECYCLE — and only its lifecycle:
        - `pending` — accepted and still running. `result` and `recovery` are both null.
        - `complete` — finished. `result` carries the verdict.

        It never carries a verdict word. A client can switch on it without knowing whether the check
        happened to finish inside the request.
      example: complete

    VerifyStatus:
      type: string
      enum:
        - deliverable
        - invalid
        - catch-all
        - unknown
      description: |
        The verification VERDICT, carried by `result.outcome` (and accepted by the list's `outcome`
        filter):
        - `deliverable` — mailbox confirmed deliverable via SMTP.
        - `invalid` — mailbox does not exist or hard-rejected.
        - `catch-all` — domain accepts all addresses; deliverability unconfirmable.
        - `unknown` — could not determine (DNS failure, transient error, or no SMTP probe data).
      example: deliverable

    VerifySubStatus:
      type: string
      enum:
        - ""
        - disposable
        - role_based
        - accept_all
        - no_dns_entries
        - mailbox_not_found
        - greylisted
        - mail_server_did_not_respond
      description: |
        Additional signal within the top-level `status`. Empty string when none applies.
        Derivation is first-match: `disposable` > `role_based` > `accept_all` >
        `no_dns_entries` > `mailbox_not_found` > `greylisted` > `mail_server_did_not_respond`.
      example: ""

    BatchStartResponse:
      type: object
      description: Response from `POST /api/v1/verify/batch` when a job is accepted.
      properties:
        job_id:
          type: string
          format: uuid
          description: Unique identifier for the batch job.
          example: 7a4e2c1b-0000-0000-0000-000000000000
        livemode:
          type: boolean
          description: >-
            `true` when the job was submitted with a LIVE key, `false` for an `ev_test_` sandbox key
            whose rows get canned verdicts and cost no credits. Read from the created job's stored
            mode, so it cannot disagree with what a later `GET` of the same job reports. It rides on
            this body — the first one you see for a new job, and the one you are likely to log —
            rather than only on the reads.
          example: true
        total_rows:
          type: integer
          description: Total number of rows in the uploaded file.
          example: 1000
        skipped_rows:
          type: integer
          description: Number of non-email/blank rows skipped during the upload scan.
          example: 5
        charged:
          type: integer
          description: Number of email addresses billed (valid-email rows only).
          example: 995
        credits_remaining:
          type: integer
          nullable: true
          description: Credit balance after deduction. `null` for system/demo accounts.
          example: 4817
        status:
          type: string
          enum:
            - processing
          description: Always `processing` immediately after job creation.
          example: processing
        callback_secret:
          type: string
          description: |
            Per-job HMAC secret. Included only when `callback_url` was provided in the request; it
            is not retrievable later. Supplying/keeping it is **optional** — it OVERRIDES the
            account signing secret for this job's delivery only. The documented default is your
            account signing secret (`whsec_…`), which you can pin once instead of tracking one
            secret per job. See `components.headers.X-Signature`.
          example: bv_1c3e5a70...

    BatchJobResponse:
      type: object
      description: State of a batch job returned by `GET /api/v1/verify/batch/{id}`.
      properties:
        job_id:
          type: string
          format: uuid
          example: 7a4e2c1b-0000-0000-0000-000000000000
        livemode:
          type: boolean
          description: >-
            `true` when the job was submitted with a LIVE key; `false` when it came from an `ev_test_`
            sandbox key. A key only ever lists — and can only ever fetch — jobs of its own mode.
          example: true
        status:
          type: string
          enum:
            - pendingapproval
            - resolving
            - smtping
            - assembling
            - complete
            - failed
          description: >-
            The job's lifecycle stage. `POST /api/v1/verify/batch` answers `processing` at creation —
            a submission acknowledgement rather than a stage — so treat anything that is not
            `complete` or `failed` as still working, and a stage added later will not break you.
          example: complete
        total_rows:
          type: integer
          description: Total rows in the uploaded file.
          example: 1000
        billed_rows:
          type: integer
          description: Number of rows that were billed.
          example: 995
        original_file_name:
          type: string
          description: File name as uploaded.
          example: emails.csv
        created_at:
          type: string
          format: date-time
          example: "2025-06-10T14:00:00Z"
        started_at:
          type: string
          format: date-time
          nullable: true
          example: "2025-06-10T14:00:01Z"
        completed_at:
          type: string
          format: date-time
          nullable: true
          description: UTC timestamp when validation completed. `null` while processing.
          example: "2025-06-10T14:00:45Z"
        expires_at:
          type: string
          format: date-time
          nullable: true
          description: UTC timestamp after which result URLs are no longer valid. `null` while processing.
          example: "2025-06-17T14:00:45Z"
        csv_url:
          type: string
          nullable: true
          description: Pre-signed download URL for the CSV result file. Valid for 7 days. `null` while processing.
          example: https://storage.example.com/results/job.csv?X-Amz-Expires=604800
        json_url:
          type: string
          nullable: true
          description: Pre-signed download URL for the JSON result file. Valid for 7 days. `null` while processing.
          example: https://storage.example.com/results/job.json?X-Amz-Expires=604800

    BatchJobListItem:
      type: object
      description: >-
        One batch job in a list page. Identical to BatchJobResponse except that the pre-signed
        download URLs are omitted — fetch the job by id to get them.
      properties:
        job_id:
          type: string
          format: uuid
          description: >-
            Unique identifier for the batch job — pass it to `GET /api/v1/verify/batch/{id}`.
          example: 7a4e2c1b-0000-0000-0000-000000000000
        livemode:
          type: boolean
          description: >-
            `true` when the job was submitted with a LIVE key; `false` when it came from an `ev_test_`
            sandbox key. A key only ever lists — and can only ever fetch — jobs of its own mode.
          example: true
        status:
          type: string
          enum:
            - pendingapproval
            - resolving
            - smtping
            - assembling
            - complete
            - failed
          description: Current job status. The same value the `status` filter accepts.
          example: complete
        total_rows:
          type: integer
          description: Total rows in the uploaded file.
          example: 1000
        billed_rows:
          type: integer
          nullable: true
          description: Number of rows that were billed. `null` for a job that never started.
          example: 995
        original_file_name:
          type: string
          description: File name as uploaded.
          example: emails.csv
        created_at:
          type: string
          format: date-time
          description: UTC timestamp when the job was submitted.
          example: "2025-06-10T14:00:00Z"
        started_at:
          type: string
          format: date-time
          nullable: true
          description: UTC timestamp when processing began. `null` while the job is unstarted.
          example: "2025-06-10T14:00:01Z"
        completed_at:
          type: string
          format: date-time
          nullable: true
          description: UTC timestamp when validation completed. `null` while processing.
          example: "2025-06-10T14:00:45Z"
        expires_at:
          type: string
          format: date-time
          description: >-
            UTC timestamp when this job's data is removed. After it the job answers 404 rather than a
            distinguishable expiry error, so read it while the job is still listed.
          example: "2025-06-17T14:00:45Z"

    BatchJobList:
      type: object
      description: A cursor-paged page of batch jobs, newest first.
      properties:
        object:
          type: string
          enum:
            - list
          description: Always `list` — the discriminator that marks a paged collection.
          example: list
        data:
          type: array
          items:
            $ref: '#/components/schemas/BatchJobListItem'
          description: The jobs on this page, ordered newest first.
        has_more:
          type: boolean
          description: >-
            Whether more jobs exist beyond this page in the direction being paged. There is no total
            count: a cursor walk reveals it, and a per-page count would be stale on arrival.
          example: true
        next_cursor:
          type: string
          description: >-
            Opaque cursor for the next page, present only when has_more is true. Send it back as
            starting_after to continue forwards, or as ending_before to continue backwards.
          example: MTc0OTU2NzIwMDAwMDAwMDAwfDdhNGUyLi...

    VerificationList:
      type: object
      description: A cursor-paged page of single-email verifications, newest first.
      properties:
        object:
          type: string
          enum:
            - list
          description: Always `list` — the discriminator that marks a paged collection.
          example: list
        data:
          type: array
          items:
            $ref: '#/components/schemas/Verification'
          description: >-
            The verifications on this page, ordered newest first — in-flight ones included, with
            `status: pending`. `credits_remaining` is null on a list row: a balance is a per-request
            figure, so read it from GET /api/v1/credits.
        has_more:
          type: boolean
          description: >-
            Whether more results exist beyond this page in the direction being paged. There is no
            total count: a cursor walk reveals it, and a per-page count would be stale on arrival.
          example: true
        next_cursor:
          type: string
          description: >-
            Opaque cursor for the next page, present only when has_more is true. Send it back as
            starting_after to continue forwards, or as ending_before to continue backwards.
          example: MTc0OTU2NzIwMDAwMDAwMDAwfDNmYTg1...

    CreditsResponse:
      type: object
      description: Current credit balance for the authenticated account.
      properties:
        credits_remaining:
          type: integer
          nullable: true
          description: Sum of `free_credits` and `paid_credits`. `null` for system/demo accounts.
          example: 4817
        free_credits:
          type: integer
          description: Complimentary credit balance.
          example: 317
        paid_credits:
          type: integer
          description: Purchased credit balance.
          example: 4500

    WebhookEnvelope:
      type: object
      description: Wrapper for all webhook deliveries.
      properties:
        id:
          type: string
          description: |
            Stable, deterministic event ID (`evt_` + SHA-256 hex of the resource ID and
            event name). Identical across retries — use it as your idempotency key.
          example: evt_3a4b5c6d...
        event:
          type: string
          enum:
            - verify.completed
            - batch.completed
            - test
          description: Event type.
          example: verify.completed
        created_at:
          type: string
          format: date-time
          description: UTC timestamp of the event.
          example: "2025-06-10T14:02:11.432Z"
        data:
          type: object
          description: |
            Event-specific payload:
            - `verify.completed` — the full `Verification` object (`status: complete`).
            - `batch.completed` — batch summary with pre-signed download URLs. Batches
              started with a `ev_test_` key emit a real signed `batch.completed` webhook of
              the same shape (with working pre-signed URLs to the canned result files).
            - `test` — `{ "message": "This is a test event." }`.

    ApiProblem:
      type: object
      description: Standard error response shape used by all error status codes.
      required:
        - status
        - code
        - error
      properties:
        status:
          type: integer
          description: HTTP status code mirrored in the body.
          example: 400
        code:
          type: string
          description: |
            Stable, machine-readable error code. Branch your code on this — never on the
            `error` text, which is prose and may be reworded at any time.
          example: invalid_email
        error:
          type: string
          description: Human-readable error message.
          example: Invalid email format.
        param:
          type: string
          description: |
            The request field the failure is attributable to. Omitted when the failure is
            not about one specific field.
          example: email
        details:
          type: array
          nullable: true
          items:
            type: string
          description: Optional per-field validation messages. Null when not applicable.
          example:
            - "email: required"
        doc_url:
          type: string
          description: Link to the documentation for this error code.
          example: https://emailvalidator.ai/docs/get-started/errors
        request_id:
          type: string
          description: |
            Correlation id for this request. Quote it in a support request — it is how a
            single call is found in the server logs.
          example: "0HNCV1TQ3PLRK:00000003"

    InsufficientCreditsProblem:
      type: object
      description: |
        The `402` body of a batch submission: the standard error envelope plus the typed credit
        numbers, kept top-level so a client can top up by `shortfall` and retry without parsing prose.
      required:
        - status
        - code
        - error
        - shortfall
        - balance
        - required
      properties:
        status:
          type: integer
          description: HTTP status code mirrored in the body.
          example: 402
        code:
          type: string
          enum:
            - insufficient_credits
          description: Stable, machine-readable error code.
          example: insufficient_credits
        error:
          type: string
          description: Human-readable error message.
          example: Insufficient credits.
        shortfall:
          type: integer
          description: Credits missing for this batch — buy at least this many, then retry.
          example: 5
        balance:
          type: integer
          description: The account's credit balance when the batch was submitted.
          example: 5
        required:
          type: integer
          description: Credits the batch would have cost.
          example: 10
        doc_url:
          type: string
          description: Link to the documentation for this error code.
          example: https://emailvalidator.ai/docs/get-started/errors
        request_id:
          type: string
          description: Correlation id for this request.
          example: "0HNCV1TQ3PLRK:00000003"

    NeedsColumnSelectionProblem:
      type: object
      description: |
        The `422` body of a batch submission whose file has several columns that could hold email
        addresses: the standard error envelope plus the candidate column names to choose between.
      required:
        - status
        - code
        - error
        - candidate_columns
      properties:
        status:
          type: integer
          description: HTTP status code mirrored in the body.
          example: 422
        code:
          type: string
          enum:
            - needs_column_selection
          description: Stable, machine-readable error code.
          example: needs_column_selection
        error:
          type: string
          description: Human-readable error message.
          example: >-
            Several columns could hold email addresses. Re-send the request with email_column
            set to the one to validate.
        candidate_columns:
          type: array
          items:
            type: string
          description: |
            The columns to choose between, as **1-based positional tokens** — `Column1` is the first
            column in the file, `Column2` the second. Re-send the request with `email_column` set to
            one of these values.

            They are positional rather than header names because a file may have no header row at
            all, and the pipeline resolves the same tokens when it later reads the file. The
            `email_column` field additionally accepts the literal header cell text, if naming the
            column is more convenient.
          example:
            - Column1
            - Column2
        doc_url:
          type: string
          description: Link to the documentation for this error code.
          example: https://emailvalidator.ai/docs/get-started/errors
        request_id:
          type: string
          description: Correlation id for this request.
          example: "0HNCV1TQ3PLRK:00000003"

paths:
  /status/pipeline:
    get:
      summary: Public validation-pipeline health signal
      operationId: getPipelineStatus
      description: |
        Aggregate, boolean-only health signal for the live validation fleet, intended for an
        external status page. Returns `200 operational` when the validation workers are alive and
        `503 degraded` when they are not.

        This endpoint is **public** — no `X-Api-Key` and no rate limit apply. It is deliberately
        **decoupled** from the liveness `/health` check and exposes **no fleet detail**: the body is
        the status word only, never worker counts, identities, queue depth, or IP addresses.
      security: []
      tags:
        - Status
      responses:
        "200":
          description: The validation pipeline is operational (at least one worker is fresh).
          content:
            text/plain:
              schema:
                type: string
                enum: [operational]
              example: operational
        "503":
          description: The validation pipeline is degraded (no fresh worker).
          content:
            text/plain:
              schema:
                type: string
                enum: [degraded]
              example: degraded

  /api/v1/verify:
    post:
      summary: Submit a single email for validation
      operationId: verifySingle
      description: |
        Submit a single email address for validation.

        **Synchronous mode** (no `callback_url`): the request blocks until validation
        completes or the `timeout` elapses.
        - Completes within timeout → `200 OK` with the full result.
        - Timeout exceeded → `202 Accepted` with a `result_url` to poll.

        **Asynchronous mode** (with `callback_url`): the request returns `202 Accepted`
        immediately. When validation completes the full result is delivered to your URL
        as a signed webhook.

        **Idempotency** — send an `Idempotency-Key` and a retry replays the original response
        instead of spending a second credit. The request identity is `email` + `timeout` +
        `callback_url`; the same key with a different request is a `422`.

        Requires the `verify` scope.
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
            maxLength: 255
          description: >-
            Client-generated key (up to 255 characters, one per logical operation) that makes a retry
            safe. A repeat with the same request replays the stored response verbatim and adds
            `Idempotency-Replayed: true`; a repeat with a different request is a `422`; a repeat while
            the first is still in flight is a `409`. Records expire after 24 hours and are scoped to
            the account, the endpoint and the key's mode (test keys and live keys are independent
            namespaces). If the first request charged you and then died before answering, the retry
            gets `202` carrying the `Verification` that charge created, in whatever state it is in at
            that moment — never a second charge and never a dead `409`. Omit the header and behaviour
            is unchanged: every call is new work.
          example: 00000000-0000-4000-8000-000000000001
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/VerifyRequest'
            examples:
              sync:
                summary: Synchronous (no callback)
                value:
                  email: alice@example.com
                  timeout: 15
              async:
                summary: Asynchronous (with callback)
                value:
                  email: alice@example.com
                  callback_url: https://yourapp.example.com/hooks/email
      responses:
        "200":
          description: >-
            Validation completed synchronously — the full `Verification` with `status: complete` and a
            populated `result`.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Verification'
        "202":
          description: |
            Accepted — async callback mode, sync timeout exceeded, or idempotency recovery. The body
            is the SAME `Verification` object a `200` returns. On the first two it is `status:
            pending`, `result` and `recovery` null, and a `result_url` to poll; in async mode it also
            carries the one-time `callback_secret`.

            Idempotency recovery is the same shape reached a different way: if a previous request with
            this `Idempotency-Key` charged you and then died before it could answer (a crash, a
            deploy, a dropped connection), the retry is handed the verification that charge already
            created, with `Idempotency-Replayed: true`. The work is never re-run and you are never
            charged twice.

            **A recovery reports the resource's CURRENT state, not the state it was in when your
            original request died.** There is no earlier response to replay here — the request that
            owed you one never wrote it — so retrying an hour later returns `status: complete` with a
            populated `result` if that is what the verification now is, rather than a `pending`
            snapshot you would have to poll past. The status code stays `202` either way: it answers
            "your request was accepted", while `status` answers "how far has it got".
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Verification'
        "400":
          description: Invalid email format or bad `callback_url`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "401":
          description: Missing or invalid API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "402":
          description: Insufficient credits.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "403":
          description: >-
            The API key authenticated but is not scoped for this endpoint (`code:
            insufficient_scope`). It is a 403 rather than a 401 on purpose — the credential is valid,
            so retrying with it can never help; a key holding the `verify` scope is needed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "404":
          description: |
            The resource a charged `Idempotency-Key` paid for can no longer be read (`code:
            idempotency_resource_gone`). The charge committed and the thing it created has since
            become unreadable — an account erasure, or the 7-day retention delete landing inside the
            key's 24-hour life.

            It carries its OWN code rather than the generic `not_found`, because the two mean opposite
            things: `not_found` is an ordinary miss you may have caused, while this is a server-side
            fault worth reporting. Retrying the same key cannot succeed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "409":
          description: >-
            A request carrying this `Idempotency-Key` is still in flight (`code:
            idempotency_in_progress`). Retry shortly — once the first request finishes, the same key
            replays its response.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "413":
          description: Request body exceeds the maximum allowed size.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "422":
          description: >-
            This `Idempotency-Key` was already used for a DIFFERENT request (`code:
            idempotency_key_reuse`). The original resource is untouched and nothing was charged.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "429":
          description: Rate limit exceeded.
          headers:
            Retry-After:
              $ref: '#/components/headers/Retry-After'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "500":
          description: >-
            Unexpected server error. The body carries code internal_error plus a request_id to
            quote in a support request, and never any exception detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'

    get:
      summary: List single-email validation results
      operationId: listVerifyResults
      description: |
        List the single-email validations submitted with this API key's account, newest first.
        This is how a result is recovered when its `id` was not kept: every other read is by id,
        and an id you no longer hold cannot be guessed.

        **In-flight checks are listed too**, with `status: pending` and a null `result` — so a
        verification submitted with a `callback_url` is findable before it finishes, not only after.
        Filter on `status` to select one lifecycle, on `outcome` to select a verdict, or on both.

        Only work submitted through the API is returned — validations run from the web dashboard
        belong to the dashboard, not to this key.

        **Paging** — the response is a cursor-paged list object. Follow `next_cursor` as
        `starting_after` to walk towards older results; pass it as `ending_before` to walk back
        towards newer ones. `has_more` tells you whether another page exists in the direction you
        are walking, and `next_cursor` is present only when it does. A cursor that will not decode
        is `400 invalid_request` with `param` naming the parameter it arrived in — answering the
        first page instead would give the `while (has_more)` drain loop a fresh cursor forever.
        Omitting the parameter is still the first page, and a cursor that decodes but points past
        the end is still an empty page.

        **Retention** — results are removed 7 days after submission. Once removed, a check is
        indistinguishable from one that never existed and `GET /api/v1/verify/{id}` answers `404`.
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
          description: Number of results to return per page. Values outside 1–100 are clamped.
          example: 25
        - name: starting_after
          in: query
          schema:
            type: string
          description: >-
            Cursor from a previous response's next_cursor. Returns the page of results immediately
            after it, walking towards older results.
          example: MTc0OTU2NzIwMDAwMDAwMDAwfDNmYTg1...
        - name: ending_before
          in: query
          schema:
            type: string
          description: >-
            Cursor from a previous response. Returns the page of results immediately before it,
            walking back towards newer results. Ignored when starting_after is also supplied, and —
            like starting_after — a value that will not decode is rejected with 400 invalid_request
            rather than silently treated as the first page, even when starting_after would have
            taken precedence.
          example: MTc0OTU2NzIwMDAwMDAwMDAwfDNmYTg1...
        - name: status
          in: query
          schema:
            type: string
            enum:
              - pending
              - complete
          description: >-
            Return only verifications in this LIFECYCLE state. Matched case-insensitively; any other
            value — including a verdict word like `deliverable` — is a 400 naming `status`, rather
            than a silently unfiltered list. The two filters have separate vocabularies on purpose.
          example: complete
        - name: outcome
          in: query
          schema:
            type: string
            enum:
              - deliverable
              - invalid
              - catch-all
              - unknown
          description: >-
            Return only verifications carrying this VERDICT. Matched case-insensitively; any other
            value — including a lifecycle word like `pending` — is a 400 naming `outcome`. Only
            completed verifications can match one, since a pending check has no outcome.
          example: deliverable
      responses:
        "200":
          description: A page of validation results, newest first.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VerificationList'
              example:
                object: list
                data:
                  - object: verification
                    id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                    created_at: "2025-06-10T14:02:09.180Z"
                    livemode: true
                    status: complete
                    email: alice@example.com
                    result:
                      outcome: deliverable
                      sub_status: ""
                      risk: none
                      free_email: false
                      account: alice
                      domain: example.com
                      details:
                        role: false
                        disposable: false
                        catch_all: false
                        mx_found: true
                        mx_record: mail.example.com
                        smtp_provider: Google
                        smtp_code: 250
                    recovery: null
                    completed_at: "2025-06-10T14:02:11.432Z"
                    credits_remaining: null
                  - object: verification
                    id: 8c1d0f22-9a44-4e31-b0aa-6f2e5c7d1234
                    created_at: "2025-06-10T14:02:10.006Z"
                    livemode: true
                    status: pending
                    email: bob@example.com
                    result: null
                    recovery: null
                    completed_at: null
                    credits_remaining: null
                    result_url: /api/v1/verify/8c1d0f22-9a44-4e31-b0aa-6f2e5c7d1234
                has_more: true
                next_cursor: MTc0OTU2NzIwMDAwMDAwMDAwfDNmYTg1...
        "400":
          description: >-
            The `status` filter is not one of the documented lifecycle states, the `outcome` filter is
            not one of the documented verdicts, or a supplied `starting_after` / `ending_before`
            cursor could not be decoded. All are `code: invalid_request` with `param` naming the
            offending parameter.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "401":
          description: Missing or invalid API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "403":
          description: >-
            The API key authenticated but is not scoped for this endpoint (`code:
            insufficient_scope`). It is a 403 rather than a 401 on purpose — the credential is valid,
            so retrying with it can never help; a key holding the `read` scope is needed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "429":
          description: Rate limit exceeded.
          headers:
            Retry-After:
              $ref: '#/components/headers/Retry-After'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "500":
          description: >-
            Unexpected server error. The body carries code internal_error plus a request_id to
            quote in a support request, and never any exception detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'

  /api/v1/verify/{id}:
    get:
      summary: Poll for a single-email validation result
      operationId: getVerifyResult
      description: |
        Fetch a single-email verification by its ID. The ID is returned by `POST /api/v1/verify` and
        appears on every list row.

        The response is the SAME `Verification` object in both states — there is no separate "pending
        stub". While processing you get `status: pending` with `result` and `recovery` null and a
        `result_url`; once finished, `status: complete` and a populated `result`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Validation ID returned by `POST /api/v1/verify`.
          example: 3fa85f64-5717-4562-b3fc-2c963f66afa6
      responses:
        "200":
          description: |
            The verification. `status: pending` while still processing (null `result`/`recovery`),
            `status: complete` once finished.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Verification'
        "401":
          description: Missing or invalid API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "403":
          description: >-
            The API key authenticated but is not scoped for this endpoint (`code:
            insufficient_scope`). It is a 403 rather than a 401 on purpose — the credential is valid,
            so retrying with it can never help; a key holding the `read` scope is needed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "404":
          description: Validation ID not found or belongs to a different key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "429":
          description: Rate limit exceeded.
          headers:
            Retry-After:
              $ref: '#/components/headers/Retry-After'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "500":
          description: >-
            Unexpected server error. The body carries code internal_error plus a request_id to
            quote in a support request, and never any exception detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'

  /api/v1/verify/batch:
    post:
      summary: Upload a CSV file and start a batch validation job
      operationId: batchVerify
      description: |
        Upload a CSV file of email addresses and start a batch validation job. The CSV
        must have an `email` column; other columns are passed through to the result file.

        Credits are deducted immediately on job start based on the number of valid email
        rows. When insufficient credits: `402` with `shortfall`, `balance`, and `required`
        in the body.

        **Test mode** — a `ev_test_` key runs the batch for free (`charged: 0`) with **no
        DNS or SMTP**. Each row's outcome is derived from the local part of its address
        (the same convention as single verify — `valid@`, `invalid@`, `catch_all@`,
        `disposable@`, `role@`, `unknown@`), and the values are **canned** (produced
        without running the live validation pipeline), so a test batch matches single
        verify exactly:

        | Local part | `Outcome` | `Risk` | Flags |
        |---|---|---|---|
        | `valid` (or anything not listed) | `deliverable` | `none` | — |
        | `role` | `deliverable` | `medium` | role |
        | `catch_all` | `catch-all` | `medium` | catch-all |
        | `invalid` | `invalid` | `high` | — |
        | `disposable` | `invalid` | `high` | disposable |
        | `unknown` | `unknown` | `low` | — |

        Test batches are capped at **1,000 valid rows**; over the cap the request returns
        `400` with `code: too_many_rows` (the machine-readable field — `error` carries prose;
        branch on `code`). The result is a **real** downloadable CSV/JSON
        (pre-signed URLs) and, when `callback_url` is supplied, a **real** signed
        `batch.completed` webhook — the same shapes a live batch produces. Test batches do
        not affect your account's lifetime dashboard stats.

        **Idempotency** — send an `Idempotency-Key` and a retry replays the original response
        instead of buying a second job. The request identity is the file NAME, a SHA-256 of the
        file's CONTENTS, the email column and the `callback_url`. Because the digest is taken over
        the decoded file bytes alone, re-sending the same file replays even if the multipart
        boundary, the chunk sizes or the transfer encoding differ — while a genuinely different
        file under the same key is refused as reuse (`422 idempotency_key_reuse`), never answered
        with the other file's results.

        Requires the `verify` scope.
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
            maxLength: 255
          description: >-
            Client-generated key (up to 255 characters, one per upload) that makes a retry safe. A
            repeat with the same upload replays the stored response verbatim and adds
            `Idempotency-Replayed: true`; a repeat with a different upload is a `422`; a repeat while
            the first is still in flight is a `409`. Records expire after 24 hours and are scoped to
            the account, the endpoint and the key's mode. If the first request charged for the batch
            and then died before answering, the retry gets `202` carrying the `BatchJobResponse` for
            the job that charge created. A replayed retry still transfers its bytes — the file name is
            only known while streaming the body — but is never charged twice.
          example: 00000000-0000-4000-8000-000000000001
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
                - file
              properties:
                file:
                  type: string
                  format: binary
                  description: >-
                    The address list to validate — a `.csv`, `.txt` or `.xlsx` file. Any other
                    extension is rejected with `code: unsupported_file_type`. Columns other than the
                    email column are passed through to the result file unchanged.
                callback_url:
                  type: string
                  format: uri
                  description: Optional HTTPS URL to receive a webhook when the batch job completes.
                email_column:
                  type: string
                  description: >-
                    Optional name of the column holding the addresses. Needed only when several
                    columns could be the email column, which is answered with `422
                    needs_column_selection` and the candidate names — re-send with this field set to
                    one of them. The API never guesses, because guessing would risk billing for the
                    wrong column.
                  example: work_email
                has_header:
                  type: string
                  description: >-
                    Optional `true`/`false` override for header detection, for the rare file whose
                    first row is misread as (or mistaken for) a header.
                  example: "true"
      responses:
        "200":
          description: Job accepted and processing started.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchStartResponse'
        "202":
          description: |
            Idempotency recovery. A previous request with this `Idempotency-Key` charged for the
            batch and then died before it could answer (a crash, a deploy, a dropped connection).
            The job exists; this response **is** that job, carries `Idempotency-Replayed: true`, and
            costs nothing. The batch is never started twice.

            The body is the same `BatchJobResponse` that `GET /api/v1/verify/batch/{id}` returns —
            not a summary of it — so a recovery reports the job's CURRENT `status` and, if it has
            already finished, its `csv_url` and `json_url` straight away. There is no earlier
            response to replay here, so this is recomputed on every retry rather than frozen: it can
            never tell you a job is running hours after it completed.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchJobResponse'
        "400":
          description: |
            Not multipart (`code: invalid_request`), missing `file` field
            (`code: invalid_request`), a file extension outside the allow-list
            (`code: unsupported_file_type`), or a rejected `callback_url`
            (`code: invalid_callback_url`). For `ev_test_` keys, a batch exceeding the
            1,000-row test cap returns `code: too_many_rows`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "401":
          description: Missing or invalid API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "403":
          description: >-
            The API key authenticated but is not scoped for this endpoint (`code:
            insufficient_scope`). It is a 403 rather than a 401 on purpose — the credential is valid,
            so retrying with it can never help; a key holding the `verify` scope is needed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "402":
          description: Insufficient credits. Body includes `shortfall`, `balance`, and `required`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InsufficientCreditsProblem'
        "404":
          description: |
            The resource a charged `Idempotency-Key` paid for can no longer be read (`code:
            idempotency_resource_gone`). The charge committed and the thing it created has since
            become unreadable — an account erasure, or the 7-day retention delete landing inside the
            key's 24-hour life.

            It carries its OWN code rather than the generic `not_found`, because the two mean opposite
            things: `not_found` is an ordinary miss you may have caused, while this is a server-side
            fault worth reporting. Retrying the same key cannot succeed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "409":
          description: Job is not in pending state (concurrent submission conflict).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "413":
          description: Upload exceeds the maximum allowed file size.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "422":
          description: |
            Ambiguous CSV — multiple candidate email columns detected.
            Re-POST with `email_column` set to the correct column name.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NeedsColumnSelectionProblem'
        "429":
          description: Rate limit exceeded.
          headers:
            Retry-After:
              $ref: '#/components/headers/Retry-After'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "500":
          description: >-
            Unexpected server error. The body carries code internal_error plus a request_id to
            quote in a support request, and never any exception detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'

    get:
      summary: List batch jobs
      operationId: listBatchJobs
      description: |
        List the batch jobs submitted with this API key's account, newest first. This is how a job is
        recovered when its `job_id` was not kept: every other read is by id, and an id you no longer
        hold cannot be guessed.

        Only work submitted through the API is returned — batches started from the web dashboard
        belong to the dashboard, not to this key.

        **Download URLs are not included.** Each pre-signed URL costs a storage round-trip, so they
        are produced only for the job you actually want: fetch the job with
        `GET /api/v1/verify/batch/{id}` to get `csv_url` and `json_url`.

        **Paging** — the response is a cursor-paged list object. Follow `next_cursor` as
        `starting_after` to walk towards older jobs; pass it as `ending_before` to walk back towards
        newer ones. `has_more` tells you whether another page exists in the direction you are
        walking, and `next_cursor` is present only when it does. A cursor that will not decode is
        `400 invalid_request` with `param` naming the parameter it arrived in — answering the first
        page instead would give the `while (has_more)` drain loop a fresh cursor forever. Omitting
        the parameter is still the first page, and a cursor that decodes but points past the end is
        still an empty page.

        **Retention** — a job's `expires_at` is when its data is removed, 7 days after submission.
        After that the job is indistinguishable from one that never existed and
        `GET /api/v1/verify/batch/{id}` answers `404`.
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
          description: Number of jobs to return per page. Values outside 1–100 are clamped.
          example: 25
        - name: starting_after
          in: query
          schema:
            type: string
          description: >-
            Cursor from a previous response's next_cursor. Returns the page of jobs immediately
            after it, walking towards older jobs.
          example: MTc0OTU2NzIwMDAwMDAwMDAwfDdhNGUyLi...
        - name: ending_before
          in: query
          schema:
            type: string
          description: >-
            Cursor from a previous response. Returns the page of jobs immediately before it, walking
            back towards newer jobs. Ignored when starting_after is also supplied, and — like
            starting_after — a value that will not decode is rejected with 400 invalid_request rather
            than silently treated as the first page, even when starting_after would have taken
            precedence.
          example: MTc0OTU2NzIwMDAwMDAwMDAwfDdhNGUyLi...
        - name: status
          in: query
          schema:
            type: string
            enum:
              - pendingapproval
              - resolving
              - smtping
              - assembling
              - complete
              - failed
          description: >-
            Return only jobs in this state. Matched case-insensitively; any other value is a 400
            rather than a silently unfiltered list.
          example: complete
      responses:
        "200":
          description: A page of batch jobs, newest first.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchJobList'
              example:
                object: list
                data:
                  - job_id: 7a4e2c1b-0000-0000-0000-000000000000
                    status: complete
                    total_rows: 1000
                    billed_rows: 995
                    original_file_name: emails.csv
                    created_at: "2025-06-10T14:00:00Z"
                    started_at: "2025-06-10T14:00:01Z"
                    completed_at: "2025-06-10T14:00:45Z"
                    expires_at: "2025-06-17T14:00:45Z"
                has_more: true
                next_cursor: MTc0OTU2NzIwMDAwMDAwMDAwfDdhNGUyLi...
        "400":
          description: >-
            The `status` filter is not one of the documented job states, or a supplied
            `starting_after` / `ending_before` cursor could not be decoded. Both are
            `code: invalid_request` with `param` naming the offending parameter.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "401":
          description: Missing or invalid API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "403":
          description: >-
            The API key authenticated but is not scoped for this endpoint (`code:
            insufficient_scope`). It is a 403 rather than a 401 on purpose — the credential is valid,
            so retrying with it can never help; a key holding the `read` scope is needed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "429":
          description: Rate limit exceeded.
          headers:
            Retry-After:
              $ref: '#/components/headers/Retry-After'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "500":
          description: >-
            Unexpected server error. The body carries code internal_error plus a request_id to
            quote in a support request, and never any exception detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'

  /api/v1/verify/batch/{id}:
    get:
      summary: Get the status of a batch job
      operationId: getBatchJob
      description: |
        Check the status of a batch job and, once complete, retrieve pre-signed download
        URLs for the result files. Pre-signed URLs are valid for 7 days from job completion;
        call this endpoint again to refresh them as long as job data is retained.

        **Test mode** — jobs started with a `ev_test_` key complete within seconds and
        expose `csv_url` / `json_url` just like a live job. The rows carry the canned
        per-address outcomes described on `POST /api/v1/verify/batch`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Batch job ID returned by `POST /api/v1/verify/batch`.
          example: 7a4e2c1b-0000-0000-0000-000000000000
      responses:
        "200":
          description: Job found. Download URLs are included when `status == "complete"`.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchJobResponse'
        "401":
          description: Missing or invalid API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "403":
          description: >-
            The API key authenticated but is not scoped for this endpoint (`code:
            insufficient_scope`). It is a 403 rather than a 401 on purpose — the credential is valid,
            so retrying with it can never help; a key holding the `read` scope is needed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "404":
          description: Job not found or belongs to a different key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "429":
          description: Rate limit exceeded.
          headers:
            Retry-After:
              $ref: '#/components/headers/Retry-After'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "500":
          description: >-
            Unexpected server error. The body carries code internal_error plus a request_id to
            quote in a support request, and never any exception detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'

  /api/v1/credits:
    get:
      summary: Get the current credit balance
      operationId: getCredits
      description: Return the current credit balance for the authenticated key's account.
      responses:
        "200":
          description: Balance returned.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreditsResponse'
              example:
                credits_remaining: 4817
                free_credits: 317
                paid_credits: 4500
        "401":
          description: Missing or invalid API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "403":
          description: >-
            The API key authenticated but is not scoped for this endpoint (`code:
            insufficient_scope`). It is a 403 rather than a 401 on purpose — the credential is valid,
            so retrying with it can never help; a key holding the `read` scope is needed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "404":
          description: The account the key was issued for no longer exists.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "429":
          description: Rate limit exceeded.
          headers:
            Retry-After:
              $ref: '#/components/headers/Retry-After'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "500":
          description: >-
            Unexpected server error. The body carries code internal_error plus a request_id to
            quote in a support request, and never any exception detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'

  /api/v1/events/stream:
    get:
      summary: Stream validation events via Server-Sent Events
      operationId: streamEvents
      description: |
        Connect to a Server-Sent Events (SSE) stream that delivers validation results in
        real time. Each event is a JSON-encoded `WebhookEnvelope`. The stream sends a
        `: heartbeat` comment periodically to keep the connection alive.

        **Population** — the stream carries exactly what the list endpoints return for the same key:
        the key's ACCOUNT (every team member's submissions, not only the key holder's), API-submitted
        work only (dashboard validations are the dashboard's), and the key's MODE (`ev_test_` keys see
        sandbox events, live keys see live ones). If a verification does not appear in
        `GET /api/v1/verify` for a key, it will not appear on that key's stream either — one rule for
        both.

        **Reconnection** — on disconnect reconnect with `Last-Event-ID` set to the last
        received event ID, or `?since=<ISO-8601>` to replay events from a specific time. The replay
        applies the same population rule as the live feed.

        Note: this endpoint is not rate-limited (reconnect loops would exhaust a
        per-minute budget).

        **Authentication** — use the `?api_key=` query parameter when a browser
        `EventSource` is used (it cannot set custom headers).
      parameters:
        - name: since
          in: query
          schema:
            type: string
          description: ISO-8601 timestamp or tick count. Returns events after this point.
          example: "2025-06-10T14:00:00Z"
        - name: Last-Event-ID
          in: header
          schema:
            type: string
          description: SSE reconnect protocol — tick count of the last received event.
      responses:
        "200":
          description: SSE stream established. Events are delivered as `text/event-stream`.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
          content:
            text/event-stream:
              schema:
                type: string
                description: |
                  Each event follows the SSE wire format:
                  ```
                  id: <ticks>
                  event: verify.completed
                  data: <WebhookEnvelope JSON>

                  ```
        "401":
          description: Missing or invalid API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "403":
          description: >-
            The API key authenticated but is not scoped for this endpoint (`code:
            insufficient_scope`). It is a 403 rather than a 401 on purpose — the credential is valid,
            so retrying with it can never help; a key holding the `read` scope is needed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "500":
          description: >-
            Unexpected server error. The body carries code internal_error plus a request_id to
            quote in a support request, and never any exception detail. Raised only before the
            SSE stream has started — once events are flowing the connection simply ends.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'

  /api/v1/webhooks/test:
    post:
      summary: Send a test webhook delivery
      operationId: testWebhook
      description: |
        Enqueue a test webhook delivery to a URL you control. Useful for verifying that
        your endpoint is reachable and that signature verification works before running
        real validations.

        The delivery arrives as `event: "test"` with `data: { "message": "This is a test event." }`,
        and is signed with the same `X-Signature` header — and the same **account signing secret** —
        as production events (`t=<unixSeconds>,v1=<hex>`; see `components.headers.X-Signature` for
        the verification recipe and the freshness window). So the secret you pin for production
        verifies a test event unchanged. It arrives at your URL within seconds.

        The response returns the `event_id` the delivery will carry as its envelope `id`, so you can
        match the event your endpoint receives to the call that produced it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - callback_url
              properties:
                callback_url:
                  type: string
                  format: uri
                  description: HTTPS URL to deliver the test event to.
                  example: https://yourapp.example.com/hooks/email
      responses:
        "202":
          description: Test event queued.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                  - event_id
                properties:
                  status:
                    type: string
                    enum: [queued]
                  event_id:
                    type: string
                    description: >-
                      The envelope `id` the delivered test event will carry — use it to correlate
                      this call with the event your endpoint receives, and to find the delivery in
                      your log.
                    example: evt_3a4b5c6d...
              example:
                status: queued
                event_id: evt_3a4b5c6d...
        "400":
          description: Missing or invalid `callback_url`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "401":
          description: Missing or invalid API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "403":
          description: >-
            The API key authenticated but is not scoped for this endpoint (`code:
            insufficient_scope`). It is a 403 rather than a 401 on purpose — the credential is valid,
            so retrying with it can never help; a key holding the `verify` scope is needed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "429":
          description: Rate limit exceeded.
          headers:
            Retry-After:
              $ref: '#/components/headers/Retry-After'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
        "500":
          description: >-
            Unexpected server error. The body carries code internal_error plus a request_id to
            quote in a support request, and never any exception detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiProblem'
