openapi: 3.1.0
info:
  title: Qualisend API
  version: "1.0.0"
  description: |
    Verify email addresses down to a real SMTP mailbox conversation and get a
    four-way verdict with a machine-readable reason code — single checks, bulk
    lists up to 1,000,000 addresses, and signed completion webhooks.

    ## Base URL

    ```
    https://app.qualisend.com/api/v1
    ```

    ## Authentication

    Pass your API key as a Bearer token. Create and manage keys in the dashboard
    under **Verification API → Keys**. The plaintext is shown once at creation.

    ```
    Authorization: Bearer qs_live_...
    ```

    Keys are scoped (`verify`, `read`). A `verify` key can also read (`GET`)
    your account; a `read` key cannot verify.

    ## Rate limits

    The limit is per workspace per minute, shared by all of the workspace's
    keys and OAuth connections (not per key): **60 req/min** on Free, **120** on
    Pro, **300** on Enterprise. Every response carries `X-RateLimit-Limit`,
    `X-RateLimit-Remaining`, and `X-RateLimit-Reset` (unix seconds); a `429`
    adds `Retry-After`.

    ## Idempotency

    `POST` endpoints accept an `Idempotency-Key` header. Retrying with the same
    key **and** body replays the original response without re-charging credits.
    Reusing a key with a different body returns `422`; a still-in-flight
    duplicate returns `409`.

    ## Errors

    Errors return `{ "error": "<human message>", "code": "<machine code>" }`
    with a conventional HTTP status. Codes include `invalid_api_key`,
    `missing_scope`, `rate_limited`, `invalid_body`, `insufficient_credits`,
    `not_found`, `idempotency_conflict`, and `internal_error`.

    ## Verdicts

    Every finished check is `deliverable`, `risky`, `undeliverable`, or
    `unknown`, refined by a `reason` code. Sub-flags (`free_mail`, `disposable`,
    `role`, `catch_all`, `full_mailbox`, `disabled`) add detail without changing
    the verdict.

    ## Webhooks

    Supply a `callback_url` on a bulk job to receive a signed POST when it
    finishes. Verify the `X-Qualisend-Signature` header (`sha256=` + HMAC-SHA256
    over the raw request body) using your workspace signing secret from
    `GET /webhooks`.
  contact:
    name: Qualisend support
    email: hello@qualisend.com
    url: https://www.qualisend.com

servers:
  - url: https://app.qualisend.com/api/v1
    description: Production

security:
  - bearerAuth: []

tags:
  - name: Verification
    description: Verify single addresses and register bulk jobs.
  - name: Jobs
    description: Poll job status, summary counts, and per-address results.
  - name: Account
    description: Credit balance and webhook configuration.

paths:
  /verify:
    post:
      tags: [Verification]
      summary: Verify a single address
      operationId: verifyEmail
      description: |
        Runs the local pipeline (syntax, DNS/MX, disposable, role, typo) and
        returns an immediate verdict, with the SMTP mailbox probe queued. Poll
        `GET /jobs/{id}` (or subscribe via the dashboard) for the final
        SMTP-confirmed result. Charged one credit per address.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
                  maxLength: 320
                  example: jane@example.com
      responses:
        "200":
          description: The local verdict, with the SMTP probe pending.
          headers:
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
          content:
            application/json:
              schema:
                type: object
                properties:
                  job_id: { type: string, format: uuid }
                  email_id: { type: string, format: uuid }
                  probe_queued: { type: boolean }
                  result: { $ref: "#/components/schemas/Result" }
              example:
                job_id: 6f1c2e0a-9b3d-4a1e-8c77-1a2b3c4d5e6f
                email_id: 2b9d4c1f-7e6a-4b2c-9d8e-0f1a2b3c4d5e
                probe_queued: true
                result:
                  email: jane@example.com
                  status: deliverable
                  score: 95
                  sub_flags: { free_mail: false, disposable: false, role: false, catch_all: false, full_mailbox: false, disabled: false }
                  did_you_mean: null
                  smtp: pending
                  reason: null
                  detail: null
        "400": { $ref: "#/components/responses/InvalidBody" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /verify/bulk:
    post:
      tags: [Verification]
      summary: Register a bulk job
      operationId: verifyBulk
      description: |
        Register a bulk verification job from a pasted blob of addresses or an
        already-uploaded CSV. Credits are reserved on the reported count and the
        list is verified asynchronously — poll `GET /jobs/{id}`, or supply a
        `callback_url` to be notified on completion. Up to 1,000,000 addresses.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: "#/components/schemas/BulkPaste"
                - $ref: "#/components/schemas/BulkCsv"
            examples:
              paste:
                summary: Pasted addresses
                value:
                  text: "jane@example.com\njohn@acme.io\nsupport@shop.co"
                  expected_count: 3
                  callback_url: https://hooks.example.com/qualisend
              csv:
                summary: Uploaded CSV
                value:
                  storage_path: 2b9d4c1f/list.csv
                  email_column: Email
                  expected_count: 12000
      responses:
        "200":
          description: The job was registered and is queued for ingestion.
          content:
            application/json:
              schema:
                type: object
                properties:
                  job_id: { type: string, format: uuid }
                  total: { type: integer, example: 12000 }
                  status: { type: string, example: pending }
        "400": { $ref: "#/components/responses/InvalidBody" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "413":
          description: The list exceeds the 1,000,000-address limit.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /jobs/{id}:
    get:
      tags: [Jobs]
      summary: Get a job
      operationId: getJob
      description: |
        Job status, per-status summary counts, and (with `include=results`) the
        paginated per-address rows.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: include
          in: query
          description: Set to `results` to include the per-address rows.
          schema: { type: string, enum: [results] }
        - name: limit
          in: query
          schema: { type: integer, default: 100, maximum: 1000 }
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
        - name: status
          in: query
          description: Filter rows by verdict.
          schema: { $ref: "#/components/schemas/Status" }
      responses:
        "200":
          description: The job.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
              example:
                id: 6f1c2e0a-9b3d-4a1e-8c77-1a2b3c4d5e6f
                type: bulk
                status: completed
                total: 12000
                processed: 12000
                progress: 100
                summary: { deliverable: 10120, risky: 640, undeliverable: 1180, unknown: 60 }
                created_at: "2026-07-22T08:00:00Z"
                completed_at: "2026-07-22T08:07:41Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: No such job in your workspace.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /credits:
    get:
      tags: [Account]
      summary: Check available credits
      operationId: getCredits
      description: Available credits for your workspace (wallet + plan).
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  credits: { type: integer, example: 1000 }
              example: { credits: 1000 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /webhooks:
    get:
      tags: [Account]
      summary: Get webhook signing secret
      operationId: getWebhookSecret
      description: |
        Your workspace webhook signing secret and scheme, for verifying the
        `X-Qualisend-Signature` on bulk-completion callbacks.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  signing_secret: { type: string, example: whsec_a1b2c3... }
                  signature_header: { type: string, example: X-Qualisend-Signature }
                  algorithm: { type: string, example: hmac-sha256 }
        "401": { $ref: "#/components/responses/Unauthorized" }

webhooks:
  jobCompleted:
    post:
      summary: Bulk job finished
      description: |
        Sent to a bulk job's `callback_url` when it completes or fails. The
        `X-Qualisend-Signature` header is `sha256=` + the HMAC-SHA256 of the raw
        body keyed by your workspace signing secret — verify it before trusting
        the payload.
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEvent" }
            example:
              event: job.completed
              job:
                id: 6f1c2e0a-9b3d-4a1e-8c77-1a2b3c4d5e6f
                status: completed
                total: 12000
                processed: 12000
                summary: { deliverable: 10120, risky: 640, undeliverable: 1180, unknown: 60 }
                source_filename: leads-q3.csv
                completed_at: "2026-07-22T08:07:41Z"
              created_at: "2026-07-22T08:07:42Z"
      responses:
        "200":
          description: Return any 2xx to acknowledge. A non-2xx or timeout is retried with backoff, up to 8 attempts over about 8 hours; a `410` stops retries at once.

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "Your API key as a Bearer token: `Authorization: Bearer qs_live_...`"

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: Retry-safe key — same key + body replays the original response.
      schema: { type: string, example: 6f1c2e0a-idem-key }

  headers:
    XRateLimitRemaining:
      description: Requests left in the current minute window.
      schema: { type: integer }

  schemas:
    Status:
      type: string
      enum: [deliverable, risky, undeliverable, unknown]
    Reason:
      type: string
      enum:
        - accepted_email
        - low_deliverability
        - low_quality
        - invalid_email
        - invalid_domain
        - rejected_email
        - timeout
        - unavailable_smtp
        - unknown
    Result:
      type: object
      properties:
        email: { type: string, format: email }
        status: { $ref: "#/components/schemas/Status" }
        score: { type: integer, minimum: 0, maximum: 100 }
        sub_flags:
          type: object
          properties:
            free_mail: { type: boolean }
            disposable: { type: boolean }
            role: { type: boolean }
            catch_all: { type: boolean }
            full_mailbox: { type: boolean }
            disabled: { type: boolean }
          additionalProperties: { type: boolean }
        did_you_mean:
          type: [string, "null"]
          description: A typo correction, e.g. "jane@gmail.com".
        smtp:
          type: string
          enum: [pending, cached, skipped]
          description: |
            `pending`: the SMTP mailbox probe is queued; poll `GET /jobs/{id}`
            for the final verdict. `cached` or `skipped`: this result is already
            final.
        reason:
          oneOf:
            - { $ref: "#/components/schemas/Reason" }
            - { type: "null" }
        detail: { type: [string, "null"] }
    BulkPaste:
      type: object
      required: [text, expected_count]
      properties:
        text:
          type: string
          description: Addresses separated by newlines, commas, semicolons, or spaces.
        expected_count: { type: integer, minimum: 1 }
        callback_url:
          type: string
          format: uri
          description: Optional https URL to receive the signed completion webhook.
    BulkCsv:
      type: object
      required: [storage_path, email_column, expected_count]
      properties:
        storage_path:
          type: string
          description: Path of a CSV already uploaded to your workspace's uploads bucket.
        email_column: { type: string }
        filename: { type: string }
        expected_count: { type: integer, minimum: 1 }
        callback_url: { type: string, format: uri }
    JobSummary:
      type: object
      properties:
        deliverable: { type: integer }
        risky: { type: integer }
        undeliverable: { type: integer }
        unknown: { type: integer }
    Job:
      type: object
      properties:
        id: { type: string, format: uuid }
        type: { type: string, enum: [single, bulk] }
        status: { type: string, enum: [pending, processing, completed, failed, canceled] }
        total: { type: integer }
        processed: { type: integer }
        progress: { type: integer, description: Percent complete (0–100). }
        summary: { $ref: "#/components/schemas/JobSummary" }
        created_at: { type: string, format: date-time }
        completed_at: { type: [string, "null"], format: date-time }
        results:
          type: array
          description: Present only with `include=results`.
          items: { $ref: "#/components/schemas/JobRow" }
        total_results: { type: integer }
    JobRow:
      type: object
      properties:
        email: { type: string, format: email }
        status: { $ref: "#/components/schemas/Status" }
        score: { type: [integer, "null"] }
        reason:
          oneOf:
            - { $ref: "#/components/schemas/Reason" }
            - { type: "null" }
        did_you_mean: { type: [string, "null"] }
        row_index: { type: [integer, "null"] }
    WebhookEvent:
      type: object
      properties:
        event: { type: string, enum: [job.completed, job.failed] }
        job:
          type: object
          properties:
            id: { type: string, format: uuid }
            status: { type: string }
            total: { type: integer }
            processed: { type: integer }
            summary: { $ref: "#/components/schemas/JobSummary" }
            source_filename: { type: [string, "null"] }
            completed_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
    Error:
      type: object
      properties:
        error: { type: string, description: Human-readable message. }
        code: { type: string, description: Stable machine-readable code. }
      example: { error: invalid API key, code: invalid_api_key }

  responses:
    Unauthorized:
      description: Missing or invalid credentials.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: invalid API key, code: invalid_api_key }
    InvalidBody:
      description: The request body failed validation.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: "invalid body", code: invalid_body }
    InsufficientCredits:
      description: Not enough credits to run the request.
      content:
        application/json:
          schema:
            allOf:
              - { $ref: "#/components/schemas/Error" }
              - type: object
                properties:
                  needed: { type: integer }
          example: { error: insufficient credits, code: insufficient_credits, needed: 1 }
    RateLimited:
      description: The workspace's per-minute limit was exceeded.
      headers:
        Retry-After:
          description: Seconds until the window resets.
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: rate limit exceeded, code: rate_limited }
