openapi: 3.1.0
info:
  title: ByeBouncer API
  version: "1.0.0"
  summary: Email, IP, phone and composite lead verification API.
  description: |
    ByeBouncer verifies email, IP addresses, phone numbers and composite leads.
    Bulk verification is available for email only, up to 5,000 emails per job.
    This reference covers the integration API and unauthenticated tools; dashboard
    JWT routes and payment callbacks are internal interfaces.

    ## Score-free validation
    Request `?result_format=validation-v1` on verification endpoints to receive
    independent `results` with `checks` and `attributes`, without scoring,
    quality bands or a contact acceptance decision. Without the selector,
    legacy v1 responses remain unchanged. Validation does not establish identity
    or ownership. Digit patterns do not skip carrier lookups in validation-v1;
    those explicitly requested lookups may now be billed at the existing unit price.

    ## Authentication
    Authenticated endpoints require a Bearer API key in the `Authorization` header.
    `/verify-email`, `/verify` (its compatibility alias), `/verify-ip`,
    `/verify-phone/local`, `/verify-phone/carrier`, `/verify-phone` and `/lead/verify` accept live or
    publishable keys when the corresponding feature is enabled. Account, bulk,
    phone blacklist and dedicated HLR endpoints require a live key. The HLR add-on
    also requires a live key.
    Get your key from the dashboard at https://byebouncer.com/dashboard.
    Public widget keys always require an explicit credit allocation, even when
    global allocation enforcement is disabled. Origin restrictions are browser
    controls and do not authenticate a non-browser caller.

    ## Rate limits
    - `/verify-email` and `/verify` share 50 req/sec + 10.000 req/hour + 100.000 req/day per live API key.
      Public keys use 10 req/sec + 1.000 req/hour, plus a shared 1.000 req/day
      budget across authenticated endpoints and 60 req/min per visitor IP.
    - `/verify-ip` and the `/verify-phone`, `/verify-phone/local`, `/verify-phone/carrier` phone family: 50 req/sec + 10,000 req/hour per live key;
      public keys use 10 req/sec + 1,000 req/hour and the shared public budgets.
    - `/lead/verify`: 20 req/sec live or 5 req/sec public, plus 10,000 req/hour
      live or 1,000 req/hour public and the shared public budgets.
    - HLR calls share 5 req/sec + 1,000 req/hour per key across dedicated and composite calls;
      default daily caps are 1,000 per key and 10,000 globally. Cache hits do not
      use the provider budget, but conclusive results still consume credits.
    - `/bulk`: 10 create/min + 120 read/min per API key.
    - Requests with Authorization: 6.000 req/min per source IP before authentication.
      Key resolution is limited to 10 concurrent lookups per process, with up to
      20 queued requests waiting at most 250 ms; total authentication timeout is 3 seconds.
    Network-based public tools return 503 if their rate limiter is unavailable.

    ## Credits
    Policy since 2026-10-03: **every billable query is charged, including cache
    hits**. The internal cache is an operational optimisation on our side and
    does not discount the answer.

    - Email: 1 credit per verification (cache hits included). For single and
      composite checks, local SMTP domain throttling is not a verification:
      `/verify` returns 429 `smtp_domain_rate_limited` with `Retry-After`, and
      composite checks report that email channel error without charging it.
      The same compatibility code covers local hosting-family limits,
      destination pauses and unavailable admission controls. The optional
      `reason` distinguishes smtp_family_rate_limited, smtp_destination_paused
      and smtp_gate_unavailable. An observation already made against an earlier
      MX remains billable if a later backup route is locally limited.
    - IP: 1 credit per public, routable IP; non-routable results are free.
    - Phone L1 (local engine only): 1 credit per valid result,
      including cache hits. Inconclusive results are free.
    - Phone L2 (enriched, carrier): 3 credits per conclusive enriched
      result, cache hits included. Inconclusive or provider-unavailable results
      are free.
    - HLR (`/verify-phone/live` or `live:true`): 6 additional credits per
      conclusive network answer, cache hits included. Invalid numbers, numbers
      on your own blacklist, and provider-unavailable lookups are free.

    Before the first paid purchase, phone lookups share a 30-credit starter quota
    (including HLR). Exhaustion returns `phone_starter_exhausted` even if the
    account still has credits.

    Phone plus HLR costs up to 9 credits. Bulk supports email only, with up to
    5,000 unique emails per job. Bulk jobs reserve credits on enqueue. Cancellation
    refunds unprocessed items; failed jobs refund the full debit.

    ## Errors
    JSON error responses include `error`; `detail` is optional. `/health` uses
    plain text instead. Common codes:
    - `invalid_api_key` (401)
    - `insufficient_credits` (402)
    - `credit_cap_not_set` (402) — assign credits to the public key in the dashboard
      (also applies to live keys when allocation enforcement is enabled)
    - `phone_starter_exhausted` (402) — purchase a pack to continue phone lookups
    - `quota_check_unavailable` (503) — phone quota could not be checked
    - `billing_restricted` (403) — account restricted by a payment dispute; contact support
    - `authentication_busy` (503) — retry according to Retry-After
    - `invalid_request` (400)
    - `too_many_emails` (400)
    - `not_found` (404)
    - `bulk_unavailable` (503) — feature flag off in this environment
  contact:
    name: ByeBouncer Support
    email: hello@byebouncer.com
    url: https://byebouncer.com/contact
  license:
    name: Proprietary
    url: https://byebouncer.com/terms
  termsOfService: https://byebouncer.com/terms

servers:
  - url: https://api.byebouncer.com
    description: Production

security:
  - BearerAuth: []

tags:
  - name: Verification
    description: Real-time email, IP, phone and composite lead verification.
  - name: Bulk
    description: Async email-only bulk verification jobs (up to 5,000 unique emails per job).
  - name: Account
    description: API key credits and metadata.
  - name: System
    description: Public system health.
  - name: Free tools
    description: Unauthenticated email, domain and IP checks.

paths:
  /api/v1/health:
    get:
      operationId: getHealth
      tags: [System]
      summary: Plain-text health probe
      description: Public, 300 requests/minute per IP. Checks PostgreSQL, Redis, active bulk processing, IP dataset freshness and the phone provider circuit when configured.
      security: []
      responses:
        "200":
          description: Healthy
          content:
            text/plain:
              schema: { type: string, const: ok }
        "503":
          description: A dependency is unhealthy
          content:
            text/plain:
              schema: { type: string, example: "degraded: postgres" }
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/suggest:
    post:
      operationId: suggestEmail
      tags: [Free tools]
      summary: Suggest an email domain correction
      description: Public, 20 requests/minute per IP. Uses local typo detection without DNS or SMTP. Invalid syntax returns has_suggestion false.
      security: []
      requestBody:
        $ref: "#/components/requestBodies/EmailInput"
      responses:
        "200":
          description: Typo suggestion
          content:
            application/json:
              schema:
                type: object
                required: [email, has_suggestion]
                properties:
                  email: { type: string }
                  has_suggestion: { type: boolean }
                  suggestion: { type: string, description: Full suggested email address. }
                  suggested_domain: { type: string }
        "400":
          $ref: "#/components/responses/BadRequest"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/domain/{domain}:
    get:
      operationId: getDomain
      tags: [Free tools]
      summary: Classify a domain and check its MX records
      description: Public, 30 requests/minute per IP. With metadata_only=1 returns cached/background SPF, DMARC and registration-age observations without MX/SMTP or credits. Pending observations do not imply an invalid mailbox. Otherwise DNS transient errors return 503 dns_temporarily_unavailable.
      security: []
      parameters:
        - $ref: "#/components/parameters/Domain"
        - in: query
          name: metadata_only
          description: Return only domain metadata without repeating mailbox validation or consuming credits.
          schema: { type: integer, enum: [0, 1], default: 0 }
      responses:
        "200":
          description: Domain classification
          content:
            application/json:
              schema:
                type: object
                required: [domain, checked_at]
                anyOf:
                  - required: [type, disposable, free_provider, has_mx]
                  - required: [domain_metadata]
                properties:
                  domain: { type: string }
                  domain_metadata:
                    type: object
                    description: SPF/DMARC published configuration and RDAP domain registration age; not mailbox ownership or message authentication. Missing observations run in the background. Nested state may be pending; validation checks project it as unavailable with background_lookup_pending.
                    properties:
                      spf: { type: object, additionalProperties: true }
                      dmarc: { type: object, additionalProperties: true }
                      domain_age: { type: object, additionalProperties: true }
                  type: { type: string, enum: [business, free, disposable, education, unknown] }
                  disposable: { type: boolean }
                  free_provider: { type: boolean }
                  has_mx: { type: boolean }
                  mx_hosts:
                    type: array
                    items: { type: string }
                  checked_at: { type: string, format: date-time }
        "400":
          $ref: "#/components/responses/BadRequest"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/ServiceUnavailable"

  /api/v1/demo/verify:
    post:
      operationId: demoVerifyEmail
      tags: [Free tools]
      summary: Run a free email verification demo
      description: Public, 5 requests/hour and 20 requests/24 hours per IP. Does not debit credits.
      security: []
      requestBody:
        $ref: "#/components/requestBodies/EmailInput"
      responses:
        "200":
          description: Email demo result
          content:
            application/json:
              schema:
                type: object
                required: [demo, email, status, action, signals, domain, response_time_ms]
                properties:
                  demo: { type: boolean, const: true }
                  email: { type: string }
                  status: { type: string, enum: [deliverable, undeliverable, risky, unknown] }
                  action: { type: string, enum: [allow, review, block] }
                  signals:
                    type: array
                    items: { type: string }
                  domain:
                    $ref: "#/components/schemas/DomainInfo"
                  suggestion: { type: string }
                  response_time_ms: { type: integer }
        "400":
          $ref: "#/components/responses/BadRequest"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          description: Verification engine failed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          $ref: "#/components/responses/ServiceUnavailable"

  /api/v1/tools/syntax:
    post:
      operationId: toolSyntax
      tags: [Free tools]
      summary: Check email syntax locally
      description: Public. Shares a 20 requests/hour budget per IP with the domain tools.
      security: []
      requestBody:
        $ref: "#/components/requestBodies/EmailInput"
      responses:
        "200": { $ref: "#/components/responses/ToolResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/tools/disposable:
    post:
      operationId: toolDisposable
      tags: [Free tools]
      summary: Check for a disposable email provider
      description: Public. Shares a 20 requests/hour budget per IP with the syntax and domain tools.
      security: []
      requestBody:
        $ref: "#/components/requestBodies/EmailInput"
      responses:
        "200": { $ref: "#/components/responses/ToolResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/tools/mx/{domain}:
    get:
      operationId: toolMX
      tags: [Free tools]
      summary: Inspect MX records
      description: Public. Shares the 20 requests/hour budget per IP for domain tools.
      security: []
      parameters:
        - $ref: "#/components/parameters/Domain"
      responses:
        "200": { $ref: "#/components/responses/ToolResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/tools/spf/{domain}:
    get:
      operationId: toolSPF
      tags: [Free tools]
      summary: Inspect SPF policy
      description: Public. Shares the 20 requests/hour budget per IP for domain tools.
      security: []
      parameters:
        - $ref: "#/components/parameters/Domain"
      responses:
        "200": { $ref: "#/components/responses/ToolResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/tools/dmarc/{domain}:
    get:
      operationId: toolDMARC
      tags: [Free tools]
      summary: Inspect DMARC policy
      description: Public. Shares the 20 requests/hour budget per IP for domain tools.
      security: []
      parameters:
        - $ref: "#/components/parameters/Domain"
      responses:
        "200": { $ref: "#/components/responses/ToolResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/tools/dkim/{domain}:
    get:
      operationId: toolDKIM
      tags: [Free tools]
      summary: Inspect DKIM policy for a selector
      description: Public. Shares the 20 requests/hour budget per IP for domain tools.
      security: []
      parameters:
        - $ref: "#/components/parameters/Domain"
        - name: selector
          in: query
          required: true
          description: A valid DNS label such as default or google.
          schema: { type: string }
      responses:
        "200": { $ref: "#/components/responses/ToolResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/tools/blacklist/{domain}:
    get:
      operationId: toolBlacklist
      tags: [Free tools]
      summary: Inspect domain IP blocklists
      description: Public. Shares the 20 requests/hour budget per IP for domain tools.
      security: []
      parameters:
        - $ref: "#/components/parameters/Domain"
      responses:
        "200": { $ref: "#/components/responses/ToolResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/tools/whois/{domain}:
    get:
      operationId: toolWHOIS
      tags: [Free tools]
      summary: Inspect public RDAP registration data
      description: Public. Shares the 20 requests/hour budget per IP for domain tools.
      security: []
      parameters:
        - $ref: "#/components/parameters/Domain"
      responses:
        "200": { $ref: "#/components/responses/ToolResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/credits/checkout:
    post:
      operationId: createCheckout
      tags: [Account]
      summary: Create a credit pack checkout
      description: |
        Live keys only, 10 requests/minute per key. Requires acceptance of the
        current terms in the purchase page. Redirect to the returned Polar checkout_url.
        transaction_id is retained as an alias of checkout_id for compatibility.
        Credits reach the shared wallet only after the verified
        payment completion webhook; existing key allocations do not increase automatically.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [pack]
              properties:
                pack:
                  type: string
                  description: Internal pack code from the credit catalog, not its display label.
                  example: M25
      responses:
        "200":
          description: Checkout created; payment is pending
          content:
            application/json:
              schema:
                type: object
                required: [transaction_id, pack, credits]
                properties:
                  transaction_id: { type: string }
                  provider: { type: string, enum: [polar] }
                  checkout_id: { type: string }
                  checkout_url: { type: string, format: uri }
                  pack: { type: string }
                  credits: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Live key required, billing restricted, or legal_acceptance_required
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502":
          description: Polar request failed (polar_create_failed)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: payments_disabled, pack_not_configured, pack_price_unavailable, or authentication unavailable
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v1/status:
    get:
      operationId: getStatus
      tags: [System]
      summary: Service health
      description: Public. Reports service status and dependencies.
      security: []
      responses:
        "200":
          description: Service status
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusResponse"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: A required dependency is degraded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusResponse"

  /api/v1/verify:
    post:
      parameters:
        - $ref: "#/components/parameters/ResultFormat"
      operationId: verifyEmail
      tags: [Verification]
      summary: Verify a single email
      description: |
        Runs the full verification pipeline (syntax → DNS/MX → domain intel → SMTP)
        and returns a verdict. Consumes 1 credit per verification, including cache hits.
        A refusal by our local SMTP domain budget returns 429
        `smtp_domain_rate_limited` with `Retry-After: 60` and consumes no credit.
        This compatibility error also covers local hosting-family limits,
        pauses and unavailable controls; optional `reason` gives the specific
        cause. A local refusal never proves mailbox invalidity.
        A completed inconclusive verification (for example a catch-all) still
        follows the normal billing policy. Retry 429 responses with backoff;
        prefer bulk for lists containing many addresses at the same domain.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VerifyRequest"
      responses:
        "200":
          description: Verification result
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/VerifyResponse"
                  - $ref: "#/components/schemas/ValidationResult"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "402":
          $ref: "#/components/responses/InsufficientCredits"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Rate limiter unavailable; retry shortly
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/verify-email:
    post:
      parameters:
        - $ref: "#/components/parameters/ResultFormat"
      operationId: verifyEmailDedicated
      tags: [Verification]
      summary: Verify a single email (dedicated endpoint)
      description: |
        Dedicated email endpoint, so each service can be requested on its own.
        Behaves exactly like `/verify`: it runs the full email pipeline
        (syntax → DNS/MX → domain intel → SMTP) and consumes 1 credit per
        verification, including cache hits. `/verify` stays as a
        backward-compatible alias with identical behavior.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VerifyRequest"
      responses:
        "200":
          description: Verification result
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/VerifyResponse"
                  - $ref: "#/components/schemas/ValidationResult"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "402":
          $ref: "#/components/responses/InsufficientCredits"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Rate limiter unavailable; retry shortly
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/verify-ip:
    post:
      parameters:
        - $ref: "#/components/parameters/ResultFormat"
      operationId: verifyIP
      tags: [Verification]
      summary: Verify a single IP address
      description: |
        Classifies an IPv4/IPv6 address (RFC 6890), enriches it with ASN, org and
        country, checks DNSBLs + FCrDNS + Tor, and returns an explainable risk
        score. Consumes 1 credit for public IPs; non-routable IPs (status
        `invalid_scope`) are not charged.
        This route is registered only when the IP datasets are loaded; otherwise 404.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VerifyIPRequest"
      responses:
        "200":
          description: IP analysis result
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/VerifyIPResponse"
                  - $ref: "#/components/schemas/ValidationResult"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "402":
          $ref: "#/components/responses/InsufficientCredits"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/verify-phone:
    post:
      parameters:
        - $ref: "#/components/parameters/ResultFormat"
      operationId: verifyPhone
      tags: [Verification]
      summary: Validate and normalize a phone number
      description: |
        Performs numbering-plan validation and, when carrier enrichment
        is configured, carrier enrichment and lead-quality scoring.
        Local valid results consume 1 credit; conclusive carrier-enriched
        results consume 3 credits, including cache hits. Invalid, possible,
        unknown, provider-unavailable, and customer-blacklisted numbers
        do not consume credits. A blacklisted L1 result includes `verdict: no`
        and the `blacklisted` flag; numbering-plan validity is unchanged.

        Send `live: true` to also get the last network status of the line
        (see `PhoneLiveAddOn`); that part is billed separately.
        The route is registered only when phone validation is enabled; otherwise 404.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VerifyPhoneRequest"
      responses:
        "200":
          description: Local or carrier-enriched phone validation result, optionally with HLR
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/VerifyPhoneResponse"
                  - $ref: "#/components/schemas/ValidationResult"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/InsufficientCredits"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/verify-phone/local:
    post:
      parameters:
        - $ref: "#/components/parameters/ResultFormat"
      operationId: verifyPhoneLocal
      tags: [Verification]
      summary: Validate a phone number locally (numbering plan only)
      description: |
        Local-only numbering-plan validation: format, country and estimated line
        type, with no carrier or network lookup. It always uses the local engine,
        even when carrier enrichment is configured, so it never silently upgrades
        to the 3-credit product. A valid result consumes 1 credit, including cache
        hits; invalid, possible, unknown and blacklisted numbers are free.
        `live: true` is rejected with 400 — use `/verify-phone/live` for the
        network status. The route is registered only when phone validation is
        enabled; otherwise 404.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VerifyPhoneLocalRequest"
      responses:
        "200":
          description: Local phone validation result
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/VerifyPhoneResponse"
                  - $ref: "#/components/schemas/ValidationResult"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/InsufficientCredits"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/verify-phone/carrier:
    post:
      parameters:
        - $ref: "#/components/parameters/ResultFormat"
      operationId: verifyPhoneCarrier
      tags: [Verification]
      summary: Look up carrier data for a phone number
      description: |
        Carrier data lookup, including the local numbering-plan validation: it
        returns the operator information when available. Conclusive results
        consume 3 credits in total (not 1 + 3), including cache hits; invalid,
        possible, unknown, provider-unavailable and blacklisted numbers are free.
        Unlike `/verify-phone`, this route never falls back to the local-only
        product: when carrier enrichment is not configured it returns 503
        `phone_carrier_unavailable`. Send `live: true` to also add the last
        network status, billed separately (see `/verify-phone/live`).
        The route is registered only when phone validation is enabled; otherwise 404.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VerifyPhoneRequest"
      responses:
        "200":
          description: Carrier-enriched phone validation result, optionally with HLR
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/VerifyPhoneResponse"
                  - $ref: "#/components/schemas/ValidationResult"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/InsufficientCredits"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Carrier enrichment is not configured (`phone_carrier_unavailable`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/verify-phone/live:
    post:
      parameters:
        - $ref: "#/components/parameters/ResultFormat"
      operationId: verifyPhoneLive
      tags: [Verification]
      summary: Check the last observed network status of a phone line (HLR)
      description: |
        Asks the mobile network (HLR) whether the line is registered and
        reachable at lookup time, so you can decide whether to call a lead right
        away. Available only when the server enables it (`PHONE_HLR_ENABLED`) and
        only for **live** API keys. Public keys receive `403 live_key_required`.
        When disabled, the dedicated route is not registered (404); a composite
        request instead returns `live_error: phone_live_unavailable`.

        `contactability` is the plain-language answer:
        `yes` (connected at checked_at), `retry_later` (device off or out of coverage, a
        real line), `unverifiable` (fixed line or VoIP, HLR does not apply) and
        `no` (invalid, unknown to the network, or on your blacklist).
        "Connected" means registered on the network at checked_at; it does not guarantee
        the person will answer.

        **Latency:** it queries the operator network, so expect a few seconds.
        Do not put it in the path of a form while the user types; call it from
        your backend once you have the lead.

        **Billing:** it costs 6 credits for
        every conclusive answer from the network (`connected`, `absent`,
        `unknown`), cache hits included. Invalid numbers, fixed lines, VoIP,
        blacklisted numbers and provider failures are free. Results are cached
        for 30 seconds by default (`cached: true`, see `checked_at`) to serve retries
        faster, but the cache does not discount the lookup. The
        original provider fields, including subscriber identifiers when supplied, are archived privately and returned to the authorized caller in `provider_data`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PhoneLiveRequest"
      responses:
        "200":
          description: Live line status
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/PhoneLiveResponse"
                  - $ref: "#/components/schemas/ValidationResult"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/InsufficientCredits"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          description: Rate limited, or the daily HLR budget was reached (`hlr_daily_limit`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: |
            Feature disabled (`phone_live_unavailable`), operator network
            unreachable (`hlr_unavailable`) or too many lookups in flight
            (`hlr_busy`), or HLR account capacity exhausted (`hlr_capacity_exhausted`).
            Nothing is charged for the failed HLR lookup. Capacity failures do not
            imply an invalid number and need account capacity restored; they do
            not advertise a five-second Retry-After. For transient failures,
            follow Retry-After when supplied.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/lead/verify:
    post:
      parameters:
        - $ref: "#/components/parameters/ResultFormat"
      operationId: verifyLead
      tags: [Verification]
      summary: Verify a lead across email, IP and phone in one call
      description: |
        Composite endpoint: runs the email, IP and phone checks in parallel and
        returns a combined `verdict` (`accept` / `review` / `reject` / `unknown`)
        plus the per-channel results. Supply any subset of `email`, `ip` and
        `phone`; at least one is required. Each channel bills independently with
        the same policy as its dedicated endpoint (email bills on every completed
        verification; local `smtp_domain_rate_limited` errors do not charge that channel; IP
        bills when publicly routable, phone bills per the service policy), so a
        full three-channel call can consume up to 5 credits with carrier enrichment or
        3 with local phone validation; optional HLR adds up to 6 more credits.
        `credits_remaining`
        reflects the balance after every channel settled.
        HTTP 200 can include per-channel `error` values; inspect each supplied
        channel even when other channels succeeded and were charged.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VerifyLeadRequest"
      responses:
        "200":
          description: Combined lead verdict with per-channel results or errors
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/VerifyLeadResponse"
                  - $ref: "#/components/schemas/ValidationResult"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/InsufficientCredits"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/phone/blacklist:
    post:
      operationId: addPhoneBlacklist
      tags: [Verification]
      summary: Add or update a number in the API key blacklist
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PhoneBlacklistRequest"
      responses:
        "200":
          description: Number is blacklisted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PhoneBlacklistResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
    delete:
      operationId: removePhoneBlacklist
      tags: [Verification]
      summary: Remove a number from the API key blacklist
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PhoneBlacklistRequest"
      responses:
        "200":
          description: Number is no longer blacklisted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PhoneBlacklistResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  # /api/v1/bulk/ip y /api/v1/bulk/phone quedaron archivados el 2026-10-03.
  # Decisión de producto: ofrecemos bulk solo para email. El código y los
  # schemas se mantienen en el repo (y los schemas de request siguen definidos
  # abajo por si se re-habilitan). Reactivar: desarchivar el handler en
  # internal/server/routes.go y reponer los bloques POST aquí.

  /api/v1/tools/ip/{ip}:
    get:
      operationId: toolIP
      tags: [Free tools]
      summary: IP reputation checker (free, no auth)
      description: |
        Public, unauthenticated, rate-limited by visitor IP (20/hour). Returns a
        reduced view (no per-signal breakdown or listed_on detail).
      security: []
      parameters:
        - in: path
          name: ip
          required: true
          schema: { type: string }
          example: "8.8.8.8"
      responses:
        "200":
          description: Reduced tool result
          content:
            application/json:
              schema:
                type: object
                properties:
                  tool: { type: string, example: ip }
                  input: { type: string }
                  status: { type: string, enum: [ok, warning, unknown] }
                  summary: { type: string }
                  details:
                    type: object
                    additionalProperties: true
                  checked_at: { type: string, format: date-time }
        "400":
          $ref: "#/components/responses/BadRequest"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: IP dependency or rate limiter unavailable; the route is absent (404) if IP validation is disabled
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/credits:
    get:
      operationId: getCredits
      tags: [Account]
      summary: Get credit balance
      description: Returns the spendable balance of the authenticated API key (remaining allocation when assigned), and the total shared account balance as wallet_balance when available.
      responses:
        "200":
          description: Balance
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreditsResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /api/v1/bulk:
    post:
      operationId: createBulkJob
      tags: [Bulk]
      summary: Create a bulk verification job
      description: |
        Enqueues up to 5.000 unique emails for verification. Debits `credits_debited`
        on creation; refunds the difference on cancel or failure.

        Accepts either JSON (recommended) or multipart form-data (with a CSV/TXT/XLSX
        file field). The optional `webhook_url` receives a POST with HMAC signature
        when the job completes.
      parameters:
        - $ref: "#/components/parameters/ResultFormat"
        - name: Idempotency-Key
          in: header
          required: false
          description: Reuse the same value when retrying to avoid a second job and debit.
          schema:
            type: string
            maxLength: 200
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateBulkRequest"
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: CSV, TXT, or XLSX file with an email column.
                filename:
                  type: string
                webhook_url:
                  type: string
                  format: uri
                webhook_secret:
                  type: string
                  minLength: 16
      responses:
        "200":
          description: Existing job returned for an idempotent replay
          content:
            application/json:
              schema:
                type: object
                required: [job, estimated_seconds]
                properties:
                  job:
                    $ref: "#/components/schemas/BulkJob"
                  estimated_seconds:
                    type: integer
        "202":
          description: Job enqueued
          content:
            application/json:
              schema:
                type: object
                required: [job, estimated_seconds]
                properties:
                  job:
                    $ref: "#/components/schemas/BulkJob"
                  estimated_seconds:
                    type: integer
                    description: Rough estimate based on domain concentration.
                  webhook_secret:
                    type: string
                    description: |
                      Effective HMAC secret (generated or supplied). Present when
                      `webhook_url` was supplied. Returned once; store it immediately.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "402":
          $ref: "#/components/responses/InsufficientCredits"
        "409":
          description: Idempotency-Key was already used with a different request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Too many active jobs or request rate exceeded
        "503":
          description: Bulk feature disabled in this environment
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/bulk/{id}:
    parameters:
      - $ref: "#/components/parameters/BulkJobId"
    get:
      operationId: getBulkJob
      tags: [Bulk]
      summary: Get a bulk job
      description: Returns job status, progress, and counters. Poll every 3-5 seconds.
      responses:
        "200":
          description: Job detail
          content:
            application/json:
              schema:
                type: object
                required: [job]
                properties:
                  job:
                    $ref: "#/components/schemas/BulkJob"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      operationId: cancelBulkJob
      tags: [Bulk]
      summary: Cancel a bulk job
      description: Cancels a pending or processing job. Refunds unprocessed credits.
      responses:
        "200":
          description: Job cancelled
          content:
            application/json:
              schema:
                type: object
                required: [job]
                properties:
                  job:
                    $ref: "#/components/schemas/BulkJob"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Job cannot be cancelled (already terminal)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/bulk/{id}/download:
    parameters:
      - $ref: "#/components/parameters/BulkJobId"
    get:
      operationId: getBulkDownloadUrl
      tags: [Bulk]
      summary: Get a signed download URL for the result CSV
      description: |
        Only available when `job.status == "completed"`. Returns a signed URL
        valid for 15 minutes (900 seconds). Request a fresh URL after expiry.
        The CSV has 10 columns:
        `email, status, action, flagged, disposable, free_provider, has_mx,
        did_you_mean, signals, response_time_ms`.
      responses:
        "200":
          description: Signed URL
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DownloadResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Result not available (job not completed)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Storage not configured on this environment
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/bulk/{id}/webhook:
    parameters:
      - $ref: "#/components/parameters/BulkJobId"
    post:
      operationId: setBulkWebhook
      tags: [Bulk]
      summary: Register or rotate the completion webhook
      description: |
        Attaches a `webhook_url` and HMAC `webhook_secret` to a still-active job.
        If you omit `webhook_secret`, one is generated (returned once in this
        response).

        On completion, ByeBouncer POSTs to `webhook_url` with headers
        `X-ByeBouncer-Event: bulk.completed` and
        `X-ByeBouncer-Signature: <hex HMAC-SHA256(body, webhook_secret)>`.

        Payload:
        ```json
        {
          "event": "bulk.completed",
          "job_id": "...",
          "processed": 1234,
          "valid": 800, "invalid": 300, "unknown": 134,
          "credits_refunded": 0,
          "timestamp": "2026-09-05T14:30:00Z"
        }
        ```

        Private-network URLs (localhost, RFC1918, link-local, multicast) are rejected.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [webhook_url]
              properties:
                webhook_url:
                  type: string
                  format: uri
                  example: https://api.myapp.com/hooks/byebouncer
                webhook_secret:
                  type: string
                  minLength: 16
                  description: Optional. If empty, a 32-byte hex secret is generated.
      responses:
        "200":
          description: Webhook attached
          content:
            application/json:
              schema:
                type: object
                required: [job_id, webhook_url, webhook_secret]
                properties:
                  job_id:
                    type: string
                    format: uuid
                  webhook_url:
                    type: string
                    format: uri
                  webhook_secret:
                    type: string
                    description: Shown only once. Store it to verify signatures.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Job already terminal (cannot register webhook)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: bb_live_*
      description: |
        API key from the dashboard. Email, IP, phone and lead verification accept
        `bb_live_...` or `bb_pub_...`; account, bulk, phone blacklist and HLR
        operations require `bb_live_...`.
        Header: `Authorization: Bearer <api-key>`.

  parameters:
    ResultFormat:
      name: result_format
      in: query
      required: false
      description: Explicit score-free result format; omit for legacy v1 compatibility.
      schema: { type: string, enum: [validation-v1] }
    Domain:
      name: domain
      in: path
      required: true
      schema: { type: string }
      example: example.com
    BulkJobId:
      name: id
      in: path
      required: true
      description: Bulk job UUID.
      schema:
        type: string
        format: uuid

  requestBodies:
    EmailInput:
      required: true
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/VerifyRequest"

  responses:
    ToolResult:
      description: Free tool result; details vary by tool.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ToolResponse"
    ServiceUnavailable:
      description: Dependency or rate limiter unavailable; retry when service recovers.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    BadRequest:
      description: Invalid request
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: invalid_api_key
    Forbidden:
      description: The API key cannot perform this operation or its origin is not allowed
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: live_key_required
    InsufficientCredits:
      description: Insufficient balance, missing key allocation, or exhausted phone starter quota
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/Error"
              - type: object
                properties:
                  credits_remaining:
                    type: integer
          example:
            error: insufficient_credits
            credits_remaining: 0
    NotFound:
      description: Resource not found (or not visible to this API key)
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: not_found
    RateLimited:
      description: Rate limit exceeded
      headers:
        Retry-After:
          schema:
            type: integer
          description: Seconds until the next window.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: rate_limit_exceeded

  schemas:
    ValidationCheck:
      type: object
      additionalProperties: false
      required: [state]
      properties:
        state: { type: string, enum: [completed, not_requested, not_applicable, unavailable, failed] }
        value: {}
        reason: { type: string }
        observed_at: { type: [string, 'null'], format: date-time }
    ValidationChannel:
      type: object
      description: |
        Email preserves the original local-part (including case, dots and tags)
        and normalizes international domains to IDNA ASCII. Additional email
        attributes include requires_smtputf8, mail_hosting, mx_records,
        catalog_version and catalog_retrieved_at (when the disposable snapshot
        contains a retrieval manifest). This date is not the time the underlying
        disposable service was last confirmed. mail_hosting is inferred from published MX hostnames;
        unknown or mixed_or_unrecognized does not identify the final mailbox host.
        domain_catalog describes exact matches backed by current disposable sources.
        domain_catalog_coverage is unavailable when source dates are unknown or
        a configured source has expired. email_catalog_sources contains per-source
        state, retrieval/expiry dates and record counts; disposable_sources identifies
        current sources supporting a positive match. A fresh supporting source can
        preserve disposable=true while another source is unavailable. Without such
        support, partial coverage never becomes disposable=false. Legacy response
        structures retain their historical catalog behavior.
        mail_server.observed_at is the DNS observation time, preserved on cache hits.
        dns_cache_expires_at bounds reuse of the observed mail routing, not its
        accuracy or future availability. dns_cache_ttl_source is recursive_response
        when the system recursive DNS reply supplies TTLs (including CNAMEs and
        negative SOA TTLs), configured_limit when only a configured duration is
        available, or uncacheable_response when a required TTL is missing.
        Reuse is capped by configuration and all required DNS dependencies.
        These attributes do not attest authoritative TTLs or DNSSEC validation.
        implicit_mx means mail routing falls back to a public A/AAAA address;
        has_mx remains false in that case while mail_server can be true.
        null_mx means the domain explicitly declines mail. DNS failures and
        inconsistent MX configuration are unavailable, never mailbox rejection.
        IP attributes.geolocation may contain a locally queried database estimate,
        including country, region, city and approximate coordinates when available.
        record.estimated distinguishes an estimate from an operator publication;
        the source, edition, expiry and attribution are included. DB-IP Lite data
        requires attribution to https://db-ip.com on pages displaying its results.
        An infrastructure match with reason reviewed_operator_profile describes
        the ASN operator only; record.connection_type remains undetermined.
        smtputf8_unsupported leaves deliverability undetermined. Server acceptance
        does not prove delivery, ownership, inbox placement or consent.
      additionalProperties: false
      required: [checks, attributes]
      properties:
        input: { type: string }
        normalized: { type: string }
        deliverability: { type: string, enum: [deliverable, undeliverable, undetermined] }
        checks:
          type: object
          additionalProperties: { $ref: '#/components/schemas/ValidationCheck' }
        attributes: { type: object, additionalProperties: true }
        observed_at: { type: [string, 'null'], format: date-time }
    ValidationResult:
      type: object
      additionalProperties: false
      required: [result_format, execution_status, results]
      properties:
        result_format: { type: string, const: validation-v1 }
        execution_status: { type: string, enum: [completed, partial] }
        results:
          type: object
          additionalProperties: false
          properties:
            email: { $ref: '#/components/schemas/ValidationChannel' }
            phone: { $ref: '#/components/schemas/ValidationChannel' }
            ip: { $ref: '#/components/schemas/ValidationChannel' }
        credits_remaining: { type: integer }
        credits_charged: { type: integer }
        checked_at: { type: string, format: date-time }
    ToolResponse:
      type: object
      required: [tool, input, status, summary, details, checked_at]
      properties:
        tool: { type: string }
        input: { type: string }
        status: { type: string, enum: [ok, warning, unknown] }
        summary: { type: string }
        details:
          type: object
          additionalProperties: true
        checked_at: { type: string, format: date-time }
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Stable machine-readable error code.
          example: invalid_request
        detail:
          type: string
          description: Human-readable explanation (subject to change).
          example: 'body must be {"email": "..."}'
        credits_remaining:
          type: integer
        phone_starter_remaining:
          type: integer
          description: Present on phone_starter_exhausted; zero when the free phone quota is exhausted.

    StatusResponse:
      type: object
      required: [status, env, uptime_secs, postgres, redis, smtp_enabled, bulk_enabled, payments_enabled, payments_environment, bulk_worker_healthy, bulk_worker_heartbeat_age_s, bulk_queue_oldest_pending_s, bulk_active_jobs, bulk_queue_healthy, ip_validator_enabled, phone_enrichment_enabled, checked_at]
      properties:
        status:
          type: string
          enum: [ok, degraded]
        uptime_secs:
          type: integer
        env:
          type: string
          example: production
        postgres:
          type: string
          enum: [ok, error]
        redis:
          type: string
          enum: [ok, error]
        smtp_enabled:
          type: boolean
          description: Configuration indicator only; it does not probe SMTP connectivity.
        bulk_enabled:
          type: boolean
          description: Feature and storage configuration indicator.
        payments_enabled:
          type: boolean
          description: False when purchases, Paddle webhooks and inbox processing are paused. Existing credits remain usable.
        payments_environment:
          type: string
          enum: [sandbox, production]
          description: Paddle environment. Sandbox purchases are test payments and do not charge real money.
        bulk_worker_healthy:
          type: boolean
          description: True when the worker heartbeat is recent and the queue is healthy.
        bulk_worker_heartbeat_age_s:
          type: integer
          description: Age of the latest worker heartbeat; -1 when none was observed.
        bulk_queue_oldest_pending_s:
          type: integer
          description: Age of the oldest pending job; 0 when the queue is empty.
        bulk_active_jobs:
          type: integer
          minimum: 0
          description: Total pending and processing jobs across all bulk types.
        bulk_queue_healthy:
          type: boolean
          description: False when the pending queue exceeds the operational threshold or cannot be read.
        ip_validator_enabled:
          type: boolean
        ip_data_age_s:
          type: integer
          description: IP dataset age in seconds; omitted when zero.
        ip_data_healthy:
          type: boolean
          description: Present when true; false is omitted. Datasets older than seven days are unhealthy.
        ip_geo_enabled:
          type: boolean
          description: Present when a GeoLite2 dataset is loaded.
        email_catalog:
          type: object
          description: |
            Loaded disposable source freshness. Warning indicates a delayed update
            while evidence is still current; unhealthy sources degrade service health.
            Retrieval time does not confirm when a disposable service was last checked.
          properties:
            healthy: { type: boolean }
            warning: { type: boolean }
            reason: { type: string }
            version: { type: string }
            snapshot_version: { type: string }
            sources:
              type: array
              items:
                type: object
                properties:
                  source_id: { type: string }
                  state: { type: string, enum: [completed, warning, unavailable] }
                  retrieved_at: { type: string, format: date-time }
                  expires_at: { type: string, format: date-time }
                  records: { type: integer }
        phone_enrichment_enabled:
          type: boolean
        phone_provider:
          type: string
        phone_circuit_state:
          type: string
          enum: [closed, open, half-open, unknown]
        checked_at:
          type: string
          format: date-time

    VerifyRequest:
      type: object
      required: [email]
      properties:
        email:
          type: string
          example: user@example.com

    PhoneBlacklistRequest:
      type: object
      additionalProperties: false
      required: [phone]
      properties:
        phone:
          type: string
          minLength: 1
          maxLength: 30
        default_country:
          type: string
          pattern: "^[A-Z]{2}$"
        reason:
          type: string
          maxLength: 500

    PhoneBlacklistResponse:
      type: object
      required: [phone_hash, blacklisted]
      properties:
        phone_hash:
          type: string
          pattern: "^[a-f0-9]{64}$"
        blacklisted:
          type: boolean
        removed:
          type: boolean

    PhoneLiveRequest:
      type: object
      required: [phone]
      properties:
        phone:
          type: string
          maxLength: 40
          example: "+5491163083418"
        default_country:
          type: string
          description: ISO 3166-1 alpha-2 code, required when `phone` has no leading `+`.
          example: AR

    PhoneProviderData:
      type: object
      description: Original responses for this phone lookup, keyed by lookup purpose (`carrier` for operator data and `hlr` for live network data). Integration names are not exposed in these keys. Integration-identifying fields are omitted and integration names in string values are redacted. Other body contents are preserved. Available with a live server key or in the authenticated dashboard; omitted for public widget keys. Includes additional fields and subscriber identifiers when supplied. Also preserved on billable cache hits; not used automatically in scoring.
      additionalProperties:
        type: object
        required: [http_status, complete, received_at]
        properties:
          http_status:
            type: integer
          content_type:
            type: string
          complete:
            type: boolean
          received_at:
            type: string
            format: date-time
          body:
            description: JSON body including additional fields, with integration-identifying keys omitted and integration names in string values redacted.
          body_text:
            type: string
            description: Non-JSON or partial response body, with integration names redacted.

    PhoneLiveInfo:
      type: object
      properties:
        status:
          type: string
          enum: [connected, absent, unknown, invalid, fixed_line, voip]
        connected:
          type: boolean
        ported:
          type: boolean
          description: The number moved to a different operator.
        roaming:
          type: boolean
        roaming_country:
          type: string
        current_network:
          type: string
        origin_network:
          type: string
        ported_network:
          type: string
        number_type:
          type: string

    PhoneLiveResponse:
      type: object
      required: [phone, valid_format, contactability, cached, duration_ms, checked_at, credits_charged, credits_remaining]
      properties:
        provider_data:
          $ref: "#/components/schemas/PhoneProviderData"
        phone:
          type: string
          description: E.164 number (the input when the format is invalid).
        national_format:
          type: string
        country:
          type: string
        country_calling_code:
          type: integer
        valid_format:
          type: boolean
        line_type:
          type: string
        carrier:
          type: string
        mcc:
          type: string
        mnc:
          type: string
        live:
          $ref: "#/components/schemas/PhoneLiveInfo"
        contactability:
          type: string
          enum: [yes, retry_later, unverifiable, "no"]
        blacklisted:
          type: boolean
        provider_used:
          type: string
        cached:
          type: boolean
        duration_ms:
          type: integer
        checked_at:
          type: string
          format: date-time
        credits_charged:
          type: integer
        credits_remaining:
          type: integer

    VerifyPhoneLocalRequest:
      type: object
      required: [phone]
      properties:
        phone:
          type: string
          description: International phone number, or national number with default_country.
          example: "+12025550123"
        default_country:
          type: string
          description: Two-letter ISO country code for interpreting national numbers.
          example: US
        live:
          type: boolean
          enum: [false]
          default: false
          description: Local-only requests reject true; use the separate mobile-network endpoint.

    VerifyPhoneRequest:
      type: object
      additionalProperties: false
      required: [phone]
      properties:
        phone:
          type: string
          minLength: 1
          maxLength: 30
          description: Phone number in international or national format.
          example: "+5491155551234"
        default_country:
          type: string
          pattern: "^[A-Z]{2}$"
          description: ISO-3166-1 alpha-2 country. Required for national-format numbers.
          example: AR
        expected_country:
          type: string
          pattern: "^[A-Z]{2}$"
          description: |
            (Enriched flow only) The ISO country the caller expects for this
            lead (form geo, business location). Fed to the scorer for
            country_match/mismatch signals. Ignored in the L1-only flow.
          example: AR
        ip_country:
          type: string
          pattern: "^[A-Z]{2}$"
          description: |
            (Enriched flow only) The ISO country resolved from the IP of the
            form submitter. When present, drives a second country-mismatch
            check independent of `expected_country`.
          example: AR
        ip:
          type: string
          description: IPv4 or IPv6 address to resolve into `ip_country` when the IP dataset is enabled.
          example: "181.46.0.1"
        live:
          type: boolean
          default: false
          description: |
            Also ask the mobile network whether the line is connected right now
            (HLR) and add it to the normal result. Opt-in: slower
            (a few seconds) and billed separately (`PHONE_HLR_CREDITS_COST`, see
            `/api/v1/verify-phone/live` for the billing rules). Requires a live
            API key. If the live part fails, the normal result is still returned
            with `live_error`; only the live part is left uncharged. A base rejection
            returns `live_error: base_phone_rejected` without querying HLR. Confirmed
            fixed/VoIP lines return `contactability: unverifiable` without an HLR charge.

    VerifyLeadRequest:
      type: object
      additionalProperties: false
      description: At least one of `email`, `ip` or `phone` must be present.
      anyOf:
        - required: [email]
          properties:
            email: { type: string, minLength: 1 }
        - required: [ip]
          properties:
            ip: { type: string, minLength: 1 }
        - required: [phone]
          properties:
            phone: { type: string, minLength: 1 }
      properties:
        ip_checks:
          $ref: "#/components/schemas/IPChecks"
        email:
          type: string
          example: user@example.com
        ip:
          type: string
          description: IPv4 or IPv6 address.
          example: "181.46.0.1"
        phone:
          type: string
          minLength: 1
          maxLength: 30
          example: "+5491155551234"
        default_country:
          type: string
          pattern: "^[A-Z]{2}$"
          description: ISO-3166-1 alpha-2. Required for national-format phone numbers.
          example: AR
        expected_country:
          type: string
          pattern: "^[A-Z]{2}$"
          example: AR
        ip_country:
          type: string
          pattern: "^[A-Z]{2}$"
          example: AR
        live:
          type: boolean
          default: false
          description: |
            Also ask the mobile network whether the line is connected right now
            (HLR) and add it to the normal result. Opt-in: slower
            (a few seconds) and billed separately (`PHONE_HLR_CREDITS_COST`, see
            `/api/v1/verify-phone/live` for the billing rules). Applies to the phone channel and can move the combined verdict. Requires a live
            API key. If the live part fails, the normal result is still returned
            with `live_error`; only the live part is left uncharged. A base rejection
            returns `live_error: base_phone_rejected` without querying HLR. Confirmed
            fixed/VoIP lines return `contactability: unverifiable` without an HLR charge.
        phone_mode:
          type: string
          enum: [local, carrier]
          description: |
            Selects the phone service for the `phone` channel. `local` forces
            numbering-plan validation only (1 credit) even when carrier
            enrichment is configured; `carrier` requests carrier data (3 credits)
            and fails that channel with `phone_carrier_unavailable` when
            enrichment is not configured. Omit to keep the default behavior
            (carrier when configured, otherwise local).

    VerifyLeadResponse:
      type: object
      required: [verdict, reasons, scoring, credits_remaining, verified_at]
      properties:
        verdict:
          type: string
          enum: [accept, review, reject, unknown]
          description: |
            Contactability recommendation under the any-contact policy. Accept when
            email or phone is favorable, even if the other is rejected or pending.
            Without a favorable channel, review takes precedence over reject;
            unknown means no usable contact evidence. IP never affects this verdict.
            HLR overrides only the phone channel. Reasons preserve rejected and
            incomplete checks, including email_unverified, alongside email_available
            and phone_available. Accept does not certify all submitted channels.
        reasons:
          type: array
          items:
            type: string
          description: Machine-readable tags explaining which channel(s) drove the verdict. `email_unverified` identifies an incomplete technical email check; `email_review` identifies other email review evidence. Both can appear when independent quality signals accompany a technical failure.
          example: ["email_block", "phone_available"]
        scoring:
          type: object
          required: [overall, based_on]
          description: |
            Contactability-v2 combines scored email and phone with relative weights
            40/35, renormalized over those present. IP is contextual only; scoring.ip
            remains 100 - ip.score for compatibility but never enters overall or
            based_on. Country comparisons do not add or subtract phone points.
            A neutral technical email failure is excluded. Overall is null without
            scorable contact evidence, including IP-only requests. Reject caps the
            score at 39, quality review at 79; technical uncertainty alone does not
            cap the remaining evidence. Accept may coexist with a low average when
            one channel is rejected. This is not a probability or completeness score.
          properties:
            version:
              type: string
              example: contactability-v2
              description: Version of contactability scoring and combination rules.
            overall:
              type: [integer, "null"]
              minimum: 0
              maximum: 100
              description: Lead score. `null` when no channel produced quality evidence; verdict can be `unknown` or `review` for an incomplete technical email check.
            email:
              type: integer
              minimum: 0
              maximum: 100
              description: Email quality. Omitted when the email channel has no usable quality evidence, including a technical verification failure without independent negative signals.
            ip:
              type: integer
              minimum: 0
              maximum: 100
              description: Contextual IP quality (`100 - risk`); excluded from overall and based_on. Omitted without a usable IP result.
            phone:
              type: integer
              minimum: 0
              maximum: 100
              description: Phone quality. With `live`, a line that is off caps it at 50 and a number the network does not know at 15. Omitted when the phone channel has no signal.
            based_on:
              type: array
              items:
                type: string
                enum: [email, phone]
              description: Contact channels that went into `overall`.
          example:
            overall: 81
            email: 100
            phone: 60
            based_on: [email, phone]
        email:
          type: object
          description: Present when `email` was supplied. Carries the per-channel error in `error` on failure.
          properties:
            email: { type: string }
            status: { type: string }
            action: { type: string }
            flagged: { type: boolean }
            signals:
              type: array
              items: { type: string }
            domain:
              $ref: "#/components/schemas/DomainInfo"
            suggestion: { type: string }
            cached: { type: boolean }
            error: { type: string }
        ip:
          type: object
          description: Present when `ip` was supplied. Same fields as the /verify-ip result plus an optional `error`.
        phone:
          type: object
          description: Present when `phone` was supplied.
          properties:
            phone: { type: string }
            normalized: { type: string }
            national_format: { type: string }
            country: { type: string }
            line_type: { type: string }
            carrier: { type: string }
            status: { type: string }
            score: { type: integer }
            verdict: { type: string }
            whatsapp: { type: string }
            reasons:
              type: array
              items: { type: string }
            flags:
              type: array
              items: { type: string }
            level: { type: string }
            cached: { type: boolean }
            error: { type: string }
            provider_data:
              $ref: "#/components/schemas/PhoneProviderData"
            country_calling_code: { type: integer }
            mcc: { type: string }
            mnc: { type: string }
            provider_used: { type: string }
            phone_hash: { type: string, pattern: "^[a-f0-9]{64}$" }
            signals:
              type: array
              items: { type: string }
            duration_ms: { type: integer }
            checked_at: { type: string, format: date-time }
            live:
              $ref: "#/components/schemas/PhoneLiveInfo"
            contactability:
              type: string
              enum: [yes, retry_later, unverifiable, "no"]
              description: Only when `live` was requested.
            live_cached:
              type: boolean
            live_checked_at:
              type: string
              format: date-time
            live_provider_used:
              type: string
            live_duration_ms:
              type: integer
            live_error:
              type: string
              description: Why the live part could not be completed (nothing was charged for it).
        credits_remaining:
          type: integer
        verified_at:
          type: string
          format: date-time

    VerifyPhoneResponse:
      description: |
        The response shape depends on whether the deployment has the
        enriched pipeline configured. Both
        variants share `phone_hash`, `country`, `line_type`, `carrier`,
        `cached`, `credits_remaining`; the enriched variant adds the
        lead-quality fields (`verdict`, `score`, `whatsapp`, `reasons`,
        `flags`, `mcc`, `mnc`, `national_format`, `provider_used`).
      allOf:
        - $ref: "#/components/schemas/VerifyPhoneL1Response"
        - $ref: "#/components/schemas/VerifyPhoneEnrichedResponse"
        - $ref: "#/components/schemas/PhoneLiveAddOn"

    PhoneLiveAddOn:
      type: object
      description: |
        Extra fields present only when the request set `live: true`. When the
        live part is charged, `credits_remaining` reflects the balance after both
        the normal lookup and the live lookup.
      properties:
        provider_data:
          $ref: "#/components/schemas/PhoneProviderData"
        live:
          $ref: "#/components/schemas/PhoneLiveInfo"
        contactability:
          type: string
          enum: [yes, retry_later, unverifiable, "no"]
        live_cached:
          type: boolean
        live_credits_charged:
          type: integer
        live_checked_at:
          type: string
          format: date-time
        live_provider_used:
          type: string
        live_duration_ms:
          type: integer
        live_error:
          type: string
          description: |
            Set instead of `live` when the live part could not be completed:
            `live_key_required`, `phone_live_unavailable`, `insufficient_credits`,
            `hlr_unavailable`, `hlr_busy`, `hlr_daily_limit`, `hlr_capacity_exhausted`,
            `base_phone_rejected`, `phone_starter_exhausted`, `quota_check_unavailable`,
            `rate_limit_exceeded` or `rate_limiter_unavailable`.

    VerifyPhoneL1Response:
      type: object
      description: Base phone fields returned by both local and enriched verification. Blacklisted local results additionally include verdict and flags.
      required:
        - is_valid
        - status
        - line_type
        - signals
        - level
        - cached
        - credits_remaining
        - response_time_ms
        - verified_at
      properties:
        phone_hash:
          type: string
          pattern: "^[a-f0-9]{64}$"
          description: HMAC-SHA-256 of the canonical E.164 number. Present for valid results.
        verdict:
          type: string
          enum: [high, medium, low, "no", unknown]
          description: Local blacklisted results return no; enriched verification may return any verdict.
        flags:
          type: array
          items: { type: string }
          description: Local blacklisted results include blacklisted; enriched verification may include other flags.
        normalized_e164:
          type: string
          description: Canonical E.164 representation. Present for valid results; returned to the caller but not persisted in clear text.
          example: "+5491155551234"
        is_valid:
          type: boolean
          description: Numbering-plan validity only; does not confirm an active line.
        status:
          type: string
          enum: [valid, invalid, possible, unknown]
        country:
          type: string
          pattern: "^[A-Z]{2}$"
          example: AR
        country_calling_code:
          type: integer
          example: 54
        line_type:
          type: string
          enum: [mobile, fixed_line, fixed_line_or_mobile, voip, toll_free, premium_rate, shared_cost, personal_number, pager, uan, voicemail, unknown]
        carrier:
          type: string
          description: Original carrier from local metadata; it may be stale after number portability.
        signals:
          type: array
          items: { type: string }
        level:
          type: string
          enum: [l1, l2, l2-cached]
        cached:
          type: boolean
        credits_remaining:
          type: integer
        response_time_ms:
          type: integer
        verified_at:
          type: string
          format: date-time

    VerifyPhoneEnrichedResponse:
      type: object
      description: |
        Response returned when the deployment has an external phone provider
        configured. Adds carrier + type from the
        provider, a `verdict` bucket for lead qualification, a 0-100 `score`,
        and a probabilistic `whatsapp` presence signal.
      properties:
        provider_data:
          $ref: "#/components/schemas/PhoneProviderData"
        phone:
          type: string
          description: The phone the caller submitted, echoed back.
        phone_hash:
          type: string
          pattern: "^[a-f0-9]{64}$"
          description: HMAC-SHA-256 of the canonical E.164 number. Absent when the number failed local validation.
        normalized:
          type: string
          description: Canonical E.164. Absent when the number was rejected before normalization.
          example: "+5491163083418"
        national_format:
          type: string
          description: Human-readable local rendering from the provider.
          example: "011 15-6308-3418"
        country:
          type: string
          pattern: "^[A-Z]{2}$"
        line_type:
          type: string
          enum: [mobile, fixed_line, fixed_line_or_mobile, voip, toll_free, premium_rate, shared_cost, personal_number, pager, uan, voicemail, unknown]
        carrier:
          type: string
          description: Carrier name reported by the provider (never local metadata in the enriched flow).
          example: "AMX Argentina S.A."
        mcc:
          type: string
          description: Mobile Country Code. Only present for mobile line types.
          example: "722"
        mnc:
          type: string
          description: Mobile Network Code.
          example: "310"
        score:
          type: integer
          minimum: 0
          maximum: 100
          description: Numeric score composing all signals. Deterministic input for `verdict`.
        verdict:
          type: string
          description: |
            Contactability bucket for the lead. Callers should read `verdict`
            for decisions and `score` for tuning thresholds.
            * `high`: contactable, safe to route to a sales rep.
            * `medium`: probably contactable but with a caveat (VoIP business, country mismatch, etc.).
            * `low`: contactable in theory; expect low reply rates (VoIP, wrong region).
            * `no`: not contactable (invalid format, blacklisted, keypad walk, premium rate).
            * `unknown`: locally valid but the enrichment provider was unavailable.
          enum: [high, medium, low, no, unknown]
        whatsapp:
          type: string
          description: |
            Probabilistic WhatsApp presence, inferred from line type, carrier
            category and country penetration. NOT a Meta lookup. See docs for
            per-value semantics.
          enum: [likely, possible, unlikely, unknown]
        reasons:
          type: array
          description: Positive contributors that pushed the score up. Stable machine-readable identifiers.
          items:
            type: string
            example: known_mobile_carrier
        flags:
          type: array
          description: Negative contributors that pushed the score down or triggered a hard reject.
          items:
            type: string
            example: consumer_voip
        provider_used:
          type: string
          enum: [carrier]
          description: Lookup purpose (carrier enrichment), without identifying the upstream integration. Absent when the pipeline degraded to local-only.
        cached:
          type: boolean
          description: True when the response was served from the local Redis cache without hitting the provider.
        duration_ms:
          type: integer
          description: End-to-end time inside the service, including cache lookup and provider call.
        checked_at:
          type: string
          format: date-time
        credits_remaining:
          type: integer

    VerifyResponse:
      type: object
      required:
        - email
        - status
        - action
        - flagged
        - signals
        - domain
        - cached
        - response_time_ms
        - verified_at
        - credits_remaining
      properties:
        email:
          type: string
          format: email
        status:
          type: string
          enum: [deliverable, undeliverable, risky, unknown]
        action:
          type: string
          enum: [allow, review, block]
        flagged:
          type: boolean
          description: True for risky and undeliverable verdicts; false for deliverable and unknown.
        signals:
          type: array
          items:
            type: string
          description: |
            Explanatory tags accumulated across pipeline layers.
            Examples: `valid_syntax`, `mx_found`, `strict_smtp_mode`,
            `mailbox_accepts_mail`, `mailbox_rejected`, `catch_all`,
            `disposable_domain`, `free_provider`, `role_account`,
            `possible_typo`, `likely_random_local_part`.
        domain:
          $ref: "#/components/schemas/DomainInfo"
        suggestion:
          type: string
          description: Suggested domain if a typo was detected; omitted otherwise.
        cached:
          type: boolean
          description: True if the result came from cache. Cache hits consume the same credit as a fresh verification.
        response_time_ms:
          type: integer
        verified_at:
          type: string
          format: date-time
        credits_remaining:
          type: integer

    DomainInfo:
      type: object
      required: [name, type, disposable, free_provider, has_mx]
      properties:
        name:
          type: string
          example: gmail.com
        type:
          type: string
          enum: [business, free, disposable, education, unknown]
        disposable:
          type: boolean
        free_provider:
          type: boolean
        has_mx:
          type: boolean

    CreditsResponse:
      type: object
      required: [api_key_id, key_prefix, credits, auto_topup]
      properties:
        api_key_id:
          type: string
          format: uuid
        key_prefix:
          type: string
          example: bb_live_a1b2c3d4
        credits:
          type: integer
          description: Spendable balance, bounded by both the key allocation and the shared wallet. Zero for an unallocated key when allocation enforcement is enabled.
        wallet_balance:
          type: integer
          description: Total shared account balance; a purchase funds the account and does not automatically increase a key allocation.
        auto_topup:
          type: boolean

    CreateBulkRequest:
      type: object
      required: [emails]
      properties:
        emails:
          type: array
          items:
            type: string
          minItems: 1
          maxItems: 5000
          description: Duplicates are removed server-side (case-insensitive).
        filename:
          type: string
          maxLength: 255
          description: Optional label shown in the dashboard.
        webhook_url:
          type: string
          format: uri
          description: |
            Optional. Rejected if pointing to localhost or private networks.
        webhook_secret:
          type: string
          minLength: 16
          description: Optional custom HMAC secret. If empty and `webhook_url` is set, one is generated.

    BulkJob:
      type: object
      description: |
        Email jobs may return to pending with next_attempt_at while a bounded
        retry or domain cooldown waits. Completed input progress is retained.
        deadline_at bounds processing; missing recorded observations remain
        undetermined and unbilled. Internal retries do not create another debit.
        Public bulk operations remain email-only; legacy job types are retained
        for historical compatibility.
      required:
        - id
        - source
        - job_type
        - status
        - total_emails
        - processed
        - valid
        - invalid
        - unknown
        - risky
        - unbilled
        - credits_debited
        - credits_refunded
        - result_available
        - created_at
        - expires_at
      properties:
        id:
          type: string
          format: uuid
        source:
          type: string
          enum: [api, dashboard]
        job_type:
          type: string
          enum: [email, ip, phone]
        status:
          type: string
          enum: [pending, processing, completed, failed, cancelled, expired]
        next_attempt_at:
          type: [string, 'null']
          format: date-time
          description: Scheduled next opportunity for email work; absent while running or terminal.
        deadline_at:
          type: [string, 'null']
          format: date-time
          description: Email processing deadline; does not guarantee a conclusive mailbox observation.
        filename:
          type: [string, "null"]
        total_emails:
          type: integer
          description: Number of input rows; for IP jobs this is the number of IP entries.
        processed:
          type: integer
        valid:
          type: integer
          description: Deliverable emails, or public/routable IPs for IP jobs.
        invalid:
          type: integer
          description: Undeliverable emails, or non-routable IPs for IP jobs.
        unknown:
          type: integer
          description: Inconclusive email results, excluding risky, or malformed/failed IP entries.
        risky:
          type: integer
          description: Risky email results; separate from unknown. Zero for IP/phone jobs.
        unbilled:
          type: integer
          description: Email rows blocked by the local SMTP quota after retries; included in unknown, excluded from charges even on cancellation.
        credits_debited:
          type: integer
        credits_refunded:
          type: integer
          description: |
            Total credits refunded so far. Non-zero on cancel or infrastructure
            failure (`csv build failed`, `storage upload failed`), or local SMTP quota exclusions.
        webhook_url:
          type: [string, "null"]
          format: uri
        error_message:
          type: [string, "null"]
        result_available:
          type: boolean
          description: True when a signed download URL can be requested.
        created_at:
          type: string
          format: date-time
        started_at:
          type: [string, "null"]
          format: date-time
        completed_at:
          type: [string, "null"]
          format: date-time
        expires_at:
          type: string
          format: date-time
          description: The result CSV is deleted after this timestamp (7 days after terminal state).
        estimated_seconds_remaining:
          type: integer
          description: Backward-compatible center of the live estimate; only present while active.
        estimated_finish_from:
          type: string
          format: date-time
          description: Lower bound of the live completion estimate.
        estimated_finish_to:
          type: string
          format: date-time
          description: Upper bound of the live completion estimate.
        progress_updated_at:
          type: string
          format: date-time
          description: Timestamp of the latest persisted worker progress signal.

    DownloadResponse:
      type: object
      required: [url, expires_in_seconds]
      properties:
        url:
          type: string
          format: uri
          description: Signed URL to the CSV. Follow with a GET.
        expires_in_seconds:
          type: integer
          example: 900

    VerifyIPRequest:
      type: object
      required: [ip]
      properties:
        ip_checks:
          $ref: "#/components/schemas/IPChecks"
        ip:
          type: string
          description: IPv4 or IPv6 address.
          example: "203.0.113.42"

    IPChecks:
      type: array
      minItems: 1
      maxItems: 8
      uniqueItems: true
      description: >-
        Optional explicit IP checks. Requires result_format=validation-v1.
        Omit to retain the existing default. Selecting only network_data, tor,
        geolocation, infrastructure, anonymization_catalog or reputation_catalog
        performs no DNS/rDNS/DNSBL calls during IP validation. Catalog coverage is
        partial; no_match does not establish the absence of VPN, proxy or abuse.
        Public IP pricing is unchanged, including cached and partial results.
      items:
        type: string
        enum: [network_data, tor, geolocation, infrastructure, anonymization_catalog, reputation_catalog, reverse_dns, dnsbl]

    CreateIPBulkRequest:
      type: object
      required: [ips]
      properties:
        ips:
          type: array
          items: { type: string }
          maxItems: 5000
          description: Up to 5,000 unique IPs. Invalid entries are preserved as invalid rows and not charged.
        filename:
          type: string
        webhook_url:
          type: string
          format: uri
        webhook_secret:
          type: string
          minLength: 16

    CreatePhoneBulkRequest:
      type: object
      required: [phones]
      properties:
        phones:
          type: array
          items: { type: string }
          maxItems: 5000
          description: |
            Up to 5,000 unique phone numbers. Valid numbers are normalized and
            deduplicated by E.164 before debit; invalid rows are reported and refunded.
          example: ["+5491155551234", "1155551234"]
        default_country:
          type: string
          minLength: 2
          maxLength: 2
          description: |
            ISO-3166-1 alpha-2 country code applied to numbers without a
            leading `+`. Rows starting with `+` override this value on a
            per-row basis. If a row lacks both `+` and the default, the
            row is reported as `unknown`.
          example: "AR"
        filename:
          type: string
        webhook_url:
          type: string
          format: uri
        webhook_secret:
          type: string
          minLength: 16

    VerifyIPResponse:
      type: object
      required: [ip, version, status, score, risk, classification, network, reputation, signals, checked_at, credits_remaining]
      properties:
        ip: { type: string, example: "203.0.113.42" }
        version: { type: integer, enum: [4, 6] }
        status:
          type: string
          enum: [ok, invalid_scope]
          description: "`invalid_scope` = non-routable IP; not charged."
        score: { type: integer, minimum: 0, maximum: 100 }
        risk: { type: string, enum: [low, medium, high, critical] }
        classification:
          type: object
          properties:
            scope:
              type: string
              enum: [public, private, cgnat, loopback, link_local, multicast, unspecified, broadcast, documentation, benchmarking, reserved]
            is_bogon: { type: boolean }
            is_cgnat: { type: boolean }
        network:
          type: object
          properties:
            asn: { type: integer }
            asn_org: { type: string }
            country: { type: string, description: ISO-3166 alpha-2 }
            country_source: { type: string, enum: [geolite2, iptoasn] }
            reverse_dns: { type: string }
            fcrdns_valid: { type: boolean }
        reputation:
          type: object
          properties:
            listed: { type: boolean, description: "Listed in any DNSBL, including policy-only PBL. This alone does not imply contact abuse or a score penalty." }
            listed_on:
              type: array
              items: { type: string }
            dnsbl:
              type: array
              items:
                type: object
                properties:
                  provider: { type: string }
                  zone: { type: string }
                  state: { type: string, enum: [listed, clear, unavailable] }
                  codes:
                    type: array
                    items: { type: string }
                    description: DNS response A-record codes. Errors and unrecognized codes are unavailable, never clear or a scored listing.
                  categories:
                    type: array
                    items: { type: string, enum: [SBL, CSS, XBL, DROP, BCL, PBL, reputation] }
                    description: Sublist categories for ZEN; reputation for other supported DNSBLs.
                  affects_score:
                    type: boolean
                    description: Whether this source counts toward DNSBL risk. PBL-only is false; mixed PBL plus abuse is true and counts once.
            tor_exit: { type: boolean }
        signals:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              weight: { type: integer }
              detail: { type: string }
        checked_at: { type: string, format: date-time }
        credits_remaining:
          type: integer
          description: Current balance. For invalid_scope requests it is returned unchanged.
