openapi: 3.1.0
info:
  title: HonestMX API
  version: 0.1.0
  summary: Deliverability checks and email research for AI agents.
  description: |
    Conservative email deliverability verification plus research tools
    (domain intelligence, email finder, candidate suggestions) served by one
    binary over REST (`/v1`), remote MCP (`POST /mcp`) and a thin web UI (`/`).

    A `deliverable` result means the target recipient was accepted while a
    randomized control recipient at the same domain was rejected. `catch_all`
    and `unknown` are intentionally not converted into guesses, and both are
    free. Every completed result has a signed Proof Receipt. This API does not send mail and does not guarantee
    delivery or inbox placement.

    **Authentication.** Every `/v1` route except `/v1/signup`, plus `/mcp`,
    requires an API key sent as `Authorization: Bearer <key>` or
    `X-API-Key: <key>`. Keys come from the operator environment (`API_KEYS`,
    unlimited) or from the `tenants` table (`edc_...` keys issued by
    `POST /v1/signup` or the `tenant create` CLI, metered by outcome units).

    **Outcome billing.** Usable `deliverable` and network-proven
    `undeliverable` outcomes cost 1 unit. `catch_all`, `unknown`, invalid input,
    and seven-day tenant result-cache hits are free. The finder costs 3 units
    only when it finds and verifies an address; inconclusive guesses are free.
    Domain checks, candidate suggestions and account reads are free. A
    submission is refused with `402 insufficient_credits` when the balance is
    lower than the units the request could consume plus the units reserved
    by the tenant's unfinished verifications.

    **Errors** are always `{"error": {"code": "...", "message": "..."}}`; the
    `code` values are enumerated in the `Error` schema. Requests are limited
    per tenant (`429 rate_limit_exceeded`, `Retry-After`), bodies to 2 MiB,
    and each REST request to 30 seconds (`408`, empty body).
  license:
    name: MIT
    identifier: MIT
servers:
  - url: /
    description: The deployment's own origin (`PUBLIC_URL`).
security:
  - bearerAuth: []
  - apiKeyAuth: []
tags:
  - name: Verification
    description: Synchronous and asynchronous mailbox verification.
  - name: Research
    description: Domain intelligence, email finder and offline candidate suggestions.
  - name: Account
    description: Self-serve signup, balance, usage and the credit ledger.
  - name: Public
    description: Web UI, service info, MCP endpoint and the OpenAPI document.
  - name: Operations
    description: Health probes and protected metrics.
  - name: Proof
    description: Signed receipts and real-world outcome feedback.
  - name: Automation
    description: Scoped API keys and signed asynchronous webhooks.
  - name: Billing
    description: Optional Stripe-backed prepaid outcome units.

paths:
  /:
    get:
      tags: [Public]
      security: []
      summary: Landing page
      description: |
        Static HTML landing page (product overview, pricing, "connect your
        agent" snippets). Served from assets embedded in the binary with
        `Cache-Control: public, max-age=300`.
      responses:
        "200":
          description: HTML page
          content:
            text/html:
              schema: { type: string }
  /app:
    get:
      tags: [Public]
      security: []
      summary: Web app
      description: |
        Single-page app for signup, key entry (kept in the browser's
        localStorage), dashboard, single and bulk verification, finder and
        domain check. It calls the same `/v1` routes documented here.
      responses:
        "200":
          description: HTML page
          content:
            text/html:
              schema: { type: string }
  /favicon.ico:
    get:
      tags: [Public]
      security: []
      summary: Site icon
      description: SVG icon embedded in the binary.
      responses:
        "200":
          description: Icon
          content:
            image/svg+xml:
              schema: { type: string }
  /llms.txt:
    get:
      tags: [Public]
      security: []
      summary: Agent-oriented product and documentation index
      responses:
        "200": { description: Plain-text index }
  /agents.md:
    get:
      tags: [Public]
      security: []
      summary: Safe workflow and verdict guidance for automated clients
      responses:
        "200": { description: Markdown guidance }
  /server.json:
    get:
      tags: [Public]
      security: []
      summary: MCP Registry-compatible server metadata
      responses:
        "200": { description: MCP server metadata }
  /docs/{document}:
    get:
      tags: [Public]
      security: []
      summary: Public methodology, billing, proof, outcome, and security documents
      parameters:
        - name: document
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: Markdown document }
        "404": { description: Unknown document }
  /static/{asset}:
    get:
      tags: [Public]
      security: []
      summary: Web UI assets
      description: |
        Embedded assets for the UI: `app.js` and `styles.css`
        (`Cache-Control: public, max-age=300`) and `config.js`, which exposes
        `publicUrl`, `purchaseUrl` and `signupEnabled` to the browser
        (`Cache-Control: no-cache`). Unknown assets return `404`.
      parameters:
        - name: asset
          in: path
          required: true
          schema:
            type: string
            enum: [app.js, styles.css, config.js]
      responses:
        "200":
          description: Asset body
          content:
            text/javascript:
              schema: { type: string }
            text/css:
              schema: { type: string }
        "404":
          $ref: "#/components/responses/Error"
  /api:
    get:
      tags: [Public]
      security: []
      summary: Service information
      responses:
        "200":
          description: Service name, version and discovery links
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ServiceInfo"
  /openapi.yaml:
    get:
      tags: [Public]
      security: []
      summary: This document
      responses:
        "200":
          description: OpenAPI 3.1 document
          content:
            application/yaml:
              schema: { type: string }
  /.well-known/honestmx-receipt-keys.json:
    get:
      tags: [Proof]
      security: []
      summary: Public Proof Receipt verification keys
      responses:
        "200":
          description: Current and retained Ed25519 public keys.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReceiptKeySet" }
  /webhooks/stripe:
    post:
      tags: [Billing]
      security: []
      summary: Stripe event receiver
      description: Verifies Stripe-Signature and idempotently applies configured prepaid packages.
      parameters:
        - name: Stripe-Signature
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200": { description: Processed, ignored, or idempotently replayed. }
        "400": { $ref: "#/components/responses/Error" }
        "503": { $ref: "#/components/responses/Error" }
  /mcp:
    post:
      tags: [Public]
      summary: Remote MCP endpoint (Streamable HTTP)
      description: |
        Model Context Protocol over Streamable HTTP, stateless with JSON
        responses (no sessions, no SSE streams). The body is a JSON-RPC 2.0
        request such as `initialize`, `tools/list` or `tools/call`; send
        `Accept: application/json, text/event-stream`. Authentication and
        per-tenant rate limits are the same as for `/v1`; the resolved tenant
        is metered exactly like the REST routes. `GET` and `DELETE` answer
        `405` because there is no session to stream or close. Tool waits may
        run up to 50 seconds, longer than the 30-second REST timeout. Tools:
        `verify_email`, `verify_emails`, `get_verification`, `get_batch`,
        `get_batch_results`, `find_email`, `get_finder_result`,
        `check_domain`, `suggest_email_candidates`, `quote_verifications`,
        `get_account`. See MCP.md.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: A JSON-RPC 2.0 request or notification.
              additionalProperties: true
            example:
              jsonrpc: "2.0"
              id: 1
              method: tools/call
              params:
                name: verify_email
                arguments:
                  email: someone@example.com
                  wait_seconds: 20
      responses:
        "200":
          description: JSON-RPC 2.0 response
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        "202":
          description: Notification accepted (no response body)
        "401":
          $ref: "#/components/responses/Error"
        "405":
          description: Method not allowed (stateless transport; only POST is served)
        "429":
          $ref: "#/components/responses/Error"

  /health/live:
    get:
      tags: [Operations]
      security: []
      summary: Process liveness
      responses:
        "200":
          description: Process is alive
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
  /health/ready:
    get:
      tags: [Operations]
      security: []
      summary: Database and worker readiness
      description: |
        Pings SQLite and, when the process runs workers, requires a worker
        heartbeat within the last 30 seconds (`503 worker_not_ready`).
      responses:
        "200":
          description: Service is ready
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ready }
        "503":
          $ref: "#/components/responses/Error"
  /status:
    get:
      tags: [Operations]
      security: []
      summary: Public status backed by the real readiness check
      responses:
        "200": { description: Database and worker are ready }
        "503":
          $ref: "#/components/responses/Error"
  /metrics:
    get:
      tags: [Operations]
      security:
        - metricsToken: []
        - metricsBearer: []
      summary: Prometheus metrics (private)
      description: |
        Requires `METRICS_TOKEN` as `X-Metrics-Token` or `Authorization:
        Bearer`. Answers `404` when no token is configured. The supported
        Caddy configuration answers `404` on the public route; reach this
        endpoint only from the private network.
      responses:
        "200":
          description: Prometheus text exposition format
          content:
            text/plain:
              schema: { type: string }
        "401":
          $ref: "#/components/responses/Error"
        "404":
          $ref: "#/components/responses/Error"
        "503":
          $ref: "#/components/responses/Error"

  /v1/signup:
    post:
      tags: [Account]
      security: []
      summary: Self-serve signup
      description: |
        Creates a metered tenant, grants `FREE_SIGNUP_CREDITS` (default 100)
        and returns the API key **once**; it is stored only as a SHA-256 hash.
        Unauthenticated, limited to `SIGNUP_RATE_LIMIT_PER_HOUR` per client
        IP, refuses disposable addresses, and answers `404 signup_disabled`
        when `SIGNUP_ENABLED=false`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SignupRequest"
      responses:
        "201":
          description: Tenant created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SignupResponse"
        "400":
          $ref: "#/components/responses/Error"
        "404":
          $ref: "#/components/responses/Error"
        "422":
          $ref: "#/components/responses/Error"
        "429":
          $ref: "#/components/responses/Error"
        "503":
          $ref: "#/components/responses/Error"

  /v1/verify:
    post:
      tags: [Verification]
      summary: Verify one address synchronously
      description: |
        Queues the address at high priority and waits up to `wait_seconds`
        (default 20, clamped to `1..=SYNC_WAIT_MAX_SECONDS`, which defaults to
        25) for the verdict. Always returns `200` with the full verification
        and a `Location` header; if `status` is still `pending`, poll
        `GET /v1/verifications/{id}`. Honours `Idempotency-Key`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VerifyRequest"
      responses:
        "200":
          description: The verification (possibly still pending)
          headers:
            Location:
              description: "`/v1/verifications/{id}`"
              schema: { type: string }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Verification"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Error"
        "402":
          $ref: "#/components/responses/Error"
        "409":
          $ref: "#/components/responses/Error"
        "422":
          $ref: "#/components/responses/Error"
        "429":
          $ref: "#/components/responses/Error"
        "503":
          $ref: "#/components/responses/Error"
  /v1/verifications:
    post:
      tags: [Verification]
      summary: Queue one address for verification
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateVerificationRequest"
      responses:
        "202":
          description: Accepted or idempotently replayed
          headers:
            Location:
              description: "`/v1/verifications/{id}`"
              schema: { type: string }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Submission"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Error"
        "402":
          $ref: "#/components/responses/Error"
        "409":
          $ref: "#/components/responses/Error"
        "422":
          $ref: "#/components/responses/Error"
        "429":
          $ref: "#/components/responses/Error"
        "503":
          $ref: "#/components/responses/Error"
  /v1/verifications/quote:
    post:
      tags: [Verification]
      summary: Quote maximum outcome units
      description: Read-only snapshot. Creates no work and reserves no balance.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/QuoteRequest" }
      responses:
        "200":
          description: Worst-case billable units after invalid and cache-free entries.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VerificationQuote" }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "413": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
  /v1/verifications/{id}/receipt:
    get:
      tags: [Proof]
      summary: Get the stable signed Proof Receipt
      parameters:
        - $ref: "#/components/parameters/ResourceId"
      responses:
        "200":
          description: Exact signed payload bytes and Ed25519 signature as base64url.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReceiptEnvelope" }
        "404": { $ref: "#/components/responses/Error" }
  /v1/verifications/{id}/receipt/subject:
    post:
      tags: [Proof]
      summary: Compare an address with a receipt binding
      description: Authenticated comparison for the service-keyed pseudonymous recipient binding.
      parameters:
        - $ref: "#/components/parameters/ResourceId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [email]
              properties:
                email: { type: string, maxLength: 1024 }
      responses:
        "200":
          description: Whether the supplied address matches.
          content:
            application/json:
              schema:
                type: object
                required: [matches]
                properties:
                  matches: { type: boolean }
        "404": { $ref: "#/components/responses/Error" }
  /v1/verifications/{id}:
    get:
      tags: [Verification]
      summary: Get a verification
      parameters:
        - $ref: "#/components/parameters/ResourceId"
      responses:
        "200":
          description: Current or completed verification
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Verification"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Error"
        "404":
          $ref: "#/components/responses/Error"
        "429":
          $ref: "#/components/responses/Error"
        "503":
          $ref: "#/components/responses/Error"
    delete:
      tags: [Verification]
      summary: Delete a verification
      description: |
        Erases the verification and its attempts immediately, ahead of the
        `DATA_RETENTION_DAYS` TTL. Credits are not refunded: the work was
        already performed and the credit ledger entry, which holds no address,
        is kept. Another tenant's id is answered `404`.
      parameters:
        - $ref: "#/components/parameters/ResourceId"
      responses:
        "204":
          description: Deleted; no body
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Error"
        "404":
          $ref: "#/components/responses/Error"
        "429":
          $ref: "#/components/responses/Error"
        "503":
          $ref: "#/components/responses/Error"
  /v1/batches:
    post:
      tags: [Verification]
      summary: Queue a list of addresses
      description: |
        Atomically queues up to `MAX_BATCH_SIZE` addresses (default 1000).
        Metered tenants need enough balance for the recalculated worst case
        plus their unfinished reservations. Cached and invalid items reserve
        zero. Honours `Idempotency-Key` and `max_billable_units`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateBatchRequest"
      responses:
        "202":
          description: Batch accepted or idempotently replayed
          headers:
            Location:
              description: "`/v1/batches/{id}`"
              schema: { type: string }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Batch"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Error"
        "402":
          $ref: "#/components/responses/Error"
        "409":
          $ref: "#/components/responses/Error"
        "413":
          $ref: "#/components/responses/Error"
        "422":
          $ref: "#/components/responses/Error"
        "429":
          $ref: "#/components/responses/Error"
        "503":
          $ref: "#/components/responses/Error"
  /v1/batches/{id}:
    get:
      tags: [Verification]
      summary: Get batch counts and conclusive coverage
      parameters:
        - $ref: "#/components/parameters/ResourceId"
      responses:
        "200":
          description: Batch summary
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Batch"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Error"
        "404":
          $ref: "#/components/responses/Error"
        "429":
          $ref: "#/components/responses/Error"
        "503":
          $ref: "#/components/responses/Error"
    delete:
      tags: [Verification]
      summary: Delete a batch and its results
      description: |
        Erases the batch and every verification in it immediately, ahead of the
        `DATA_RETENTION_DAYS` TTL. Credits are not refunded. Another tenant's
        id is answered `404`.
      parameters:
        - $ref: "#/components/parameters/ResourceId"
      responses:
        "204":
          description: Deleted; no body
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Error"
        "404":
          $ref: "#/components/responses/Error"
        "429":
          $ref: "#/components/responses/Error"
        "503":
          $ref: "#/components/responses/Error"
  /v1/batches/{id}/results:
    get:
      tags: [Verification]
      summary: Page through batch results
      parameters:
        - $ref: "#/components/parameters/ResourceId"
        - name: cursor
          in: query
          description: The `next_cursor` of the previous page (a verification id).
          schema: { type: string, format: uuid }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 1000, default: 100 }
      responses:
        "200":
          description: A page of verification resources
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BatchResults"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Error"
        "404":
          $ref: "#/components/responses/Error"
        "429":
          $ref: "#/components/responses/Error"
        "503":
          $ref: "#/components/responses/Error"

  /v1/outcomes:
    post:
      tags: [Proof]
      summary: Report an observed delivery outcome
      description: Stores feedback idempotently. Client assertions never trigger automatic refunds.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateOutcomeRequest" }
      responses:
        "201":
          description: Outcome recorded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Outcome" }
        "200":
          description: Idempotent replay.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Outcome" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
  /v1/outcomes/{id}:
    get:
      tags: [Proof]
      summary: Get tenant-scoped outcome feedback
      parameters:
        - $ref: "#/components/parameters/ResourceId"
      responses:
        "200":
          description: Outcome and review state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Outcome" }
        "404": { $ref: "#/components/responses/Error" }
  /v1/api-keys:
    get:
      tags: [Automation]
      summary: List API keys
      responses:
        "200":
          description: Keys without secret material.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ApiKey" }
        "403": { $ref: "#/components/responses/Error" }
    post:
      tags: [Automation]
      summary: Create a scoped API key
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateApiKeyRequest" }
      responses:
        "201":
          description: Secret is returned exactly once.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreatedApiKey" }
        "403": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
  /v1/api-keys/{id}:
    delete:
      tags: [Automation]
      summary: Revoke an API key immediately
      parameters:
        - $ref: "#/components/parameters/ApiKeyId"
      responses:
        "204": { description: Revoked. }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
  /v1/billing:
    get:
      tags: [Billing]
      summary: Billing availability, balance, and configured packages
      responses:
        "200":
          description: Billing configuration safe for clients.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Billing" }
        "403": { $ref: "#/components/responses/Error" }
  /v1/billing/checkout:
    post:
      tags: [Billing]
      summary: Create a Stripe Checkout Session
      description: The server selects the Price ID and units from the configured package.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [package_id]
              properties:
                package_id: { type: string }
      responses:
        "201":
          description: Checkout redirect URL.
          content:
            application/json:
              schema:
                type: object
                required: [checkout_session_id, url]
                properties:
                  checkout_session_id: { type: string }
                  url: { type: string, format: uri }
        "403": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
        "503": { $ref: "#/components/responses/Error" }
  /v1/webhook-endpoints:
    get:
      tags: [Automation]
      summary: List signed webhook endpoints
      responses:
        "200":
          description: Tenant endpoints.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/WebhookEndpoint" }
        "403": { $ref: "#/components/responses/Error" }
    post:
      tags: [Automation]
      summary: Register an HTTPS public webhook endpoint
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [url]
              properties:
                url: { type: string, format: uri }
                description: { type: string, maxLength: 200 }
      responses:
        "201":
          description: Endpoint registered after DNS/SSRF validation.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpoint" }
        "403": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
  /v1/webhook-endpoints/{id}:
    delete:
      tags: [Automation]
      summary: Disable a webhook endpoint
      parameters:
        - $ref: "#/components/parameters/ResourceId"
      responses:
        "204": { description: Disabled. }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }

  /v1/account:
    get:
      tags: [Account]
      summary: Balance and 30-day usage
      description: |
        Operator keys (`API_KEYS`) have no tenant row: `name` and `credits`
        are `null` and `unlimited` is `true`.
      responses:
        "200":
          description: Account summary
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Account"
        "401":
          $ref: "#/components/responses/Error"
        "429":
          $ref: "#/components/responses/Error"
        "503":
          $ref: "#/components/responses/Error"
  /v1/account/ledger:
    get:
      tags: [Account]
      summary: Credit ledger
      description: Every grant, purchase, charge and adjustment, newest first.
      parameters:
        - name: cursor
          in: query
          description: Opaque `next_cursor` from the previous page.
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        "200":
          description: A page of ledger entries
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LedgerPage"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Error"
        "429":
          $ref: "#/components/responses/Error"
        "503":
          $ref: "#/components/responses/Error"

  /v1/domains/{domain}:
    get:
      tags: [Research]
      summary: Domain intelligence (free)
      description: |
        Provider, MX hosts, mail service, catch-all, disposable/webmail flags,
        SPF and DMARC presence, the learned address pattern and when DNS was
        observed. Results are cached for 7 days. `catch_all` is decided by one
        randomized probe through the normal verification queue; it is `null`
        when that probe was inconclusive (retried after an hour) and the
        whole record is refreshed after 10 minutes when DNS was temporarily
        unavailable (`status: unknown`). A DNS failure yields `null` fields,
        not an error.
      parameters:
        - name: domain
          in: path
          required: true
          description: A DNS name without `@`; IDNA-normalized and lowercased.
          schema: { type: string, maxLength: 1024 }
      responses:
        "200":
          description: Domain intelligence
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainIntel"
        "401":
          $ref: "#/components/responses/Error"
        "422":
          $ref: "#/components/responses/Error"
        "429":
          $ref: "#/components/responses/Error"
        "503":
          $ref: "#/components/responses/Error"
  /v1/finder:
    post:
      tags: [Research]
      summary: Find a person's email address
      description: |
        Generates candidate local parts from patterns already learned for the
        domain, then from global priors, and verifies them one by one at high
        priority until the first `deliverable` hit (at most 8 probes). Waits
        up to `wait_seconds` (default 20, clamped to `1..=SYNC_WAIT_MAX_SECONDS`)
        and returns `200` with the result, which may still be `pending`; poll
        `GET /v1/finder/{id}`. A metered account must hold at least 3 units;
        only a verified `found` result is charged.
        Charges: 3 when `found`, 0 otherwise. Domains
        without mail service complete as `not_found` immediately and free.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/FinderRequest"
      responses:
        "200":
          description: The finder result (possibly still pending)
          headers:
            Location:
              description: "`/v1/finder/{id}`"
              schema: { type: string }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FinderResult"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Error"
        "402":
          $ref: "#/components/responses/Error"
        "422":
          $ref: "#/components/responses/Error"
        "429":
          $ref: "#/components/responses/Error"
        "503":
          $ref: "#/components/responses/Error"
  /v1/finder/candidates:
    post:
      tags: [Research]
      summary: Suggest candidate addresses (free, offline)
      description: |
        Returns the ordered candidate list the finder would probe (learned
        domain patterns first, then global priors) without contacting
        anything. At most 8 candidates.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CandidatesRequest"
      responses:
        "200":
          description: Ordered candidates
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CandidateSuggestions"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Error"
        "422":
          $ref: "#/components/responses/Error"
        "429":
          $ref: "#/components/responses/Error"
        "503":
          $ref: "#/components/responses/Error"
  /v1/finder/{id}:
    get:
      tags: [Research]
      summary: Get a finder request
      parameters:
        - $ref: "#/components/parameters/ResourceId"
      responses:
        "200":
          description: Current or completed finder result
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FinderResult"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Error"
        "404":
          $ref: "#/components/responses/Error"
        "429":
          $ref: "#/components/responses/Error"
        "503":
          $ref: "#/components/responses/Error"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "API key supplied as an `Authorization: Bearer` credential."
    apiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: API key supplied in the `X-API-Key` header.
    metricsToken:
      type: apiKey
      in: header
      name: X-Metrics-Token
      description: "`METRICS_TOKEN`, used only by `GET /metrics`."
    metricsBearer:
      type: http
      scheme: bearer
      description: "`METRICS_TOKEN` as a Bearer credential, used only by `GET /metrics`."

  parameters:
    ResourceId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    ApiKeyId:
      name: id
      in: path
      required: true
      description: UUID for a new key or `legacy:{tenant_uuid}` for the migrated primary key.
      schema: { type: string }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        1–200 visible ASCII characters (no commas). Replaying a key with the
        same body returns the original resource; replaying it with a different
        body returns `409 idempotency_conflict`. Keys are scoped per tenant.
      schema: { type: string, minLength: 1, maxLength: 200 }

  responses:
    Error:
      description: Error response
      headers:
        Retry-After:
          description: Present on `429` responses (seconds).
          schema: { type: integer }
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

  schemas:
    QuoteRequest:
      type: object
      additionalProperties: false
      required: [emails]
      properties:
        emails:
          type: array
          minItems: 1
          maxItems: 1000
          items: { type: string, maxLength: 1024 }
    VerificationQuote:
      type: object
      required: [total, invalid_or_unsupported_free, cached_free, maximum_billable]
      properties:
        total: { type: integer, minimum: 1 }
        invalid_or_unsupported_free: { type: integer, minimum: 0 }
        cached_free: { type: integer, minimum: 0 }
        maximum_billable: { type: integer, minimum: 0 }
        available_units: { type: [integer, "null"], minimum: 0 }
    ReceiptEnvelope:
      type: object
      required: [version, algorithm, key_id, payload, signature]
      properties:
        version: { type: string, const: "1" }
        algorithm: { type: string, const: Ed25519 }
        key_id: { type: string }
        payload: { type: string, description: Unpadded base64url exact signed JSON bytes. }
        signature: { type: string, description: Unpadded base64url Ed25519 signature. }
    ReceiptKeySet:
      type: object
      required: [keys]
      properties:
        keys:
          type: array
          items:
            type: object
            required: [key_id, algorithm, public_key, created_at]
            properties:
              key_id: { type: string }
              algorithm: { type: string, const: Ed25519 }
              public_key: { type: string, description: Raw 32-byte Ed25519 key as base64url. }
              created_at: { type: string, format: date-time }
    CreateOutcomeRequest:
      type: object
      additionalProperties: false
      required: [verification_id, outcome, observed_at]
      properties:
        verification_id: { type: string, format: uuid }
        outcome:
          type: string
          enum: [delivered, invalid_recipient, temporary_failure, policy_rejection, unknown]
        smtp_code: { type: integer, minimum: 0, maximum: 65535 }
        enhanced_code: { type: string, maxLength: 64 }
        provider_event_id: { type: string, maxLength: 256 }
        evidence_digest: { type: string, maxLength: 256 }
        observed_at: { type: string, format: date-time }
    Outcome:
      type: object
      required: [id, verification_id, outcome, observed_at, review_status, refunded, created_at]
      properties:
        id: { type: string, format: uuid }
        verification_id: { type: string, format: uuid }
        outcome:
          type: string
          enum: [delivered, invalid_recipient, temporary_failure, policy_rejection, unknown]
        smtp_code: { type: [integer, "null"] }
        enhanced_code: { type: [string, "null"] }
        provider_event_id: { type: [string, "null"] }
        evidence_digest: { type: [string, "null"] }
        observed_at: { type: string, format: date-time }
        review_status: { type: string, enum: [recorded, pending_review, approved, rejected] }
        refunded: { type: boolean }
        created_at: { type: string, format: date-time }
    CreateApiKeyRequest:
      type: object
      additionalProperties: false
      required: [name, scopes]
      properties:
        name: { type: string, minLength: 1, maxLength: 100 }
        scopes:
          type: array
          minItems: 1
          uniqueItems: true
          items:
            type: string
            enum:
              - "account:read"
              - "verify:write"
              - "finder:write"
              - "outcomes:write"
              - "webhooks:manage"
              - "billing:manage"
              - "keys:manage"
        daily_limit: { type: integer, minimum: 0 }
        monthly_limit: { type: integer, minimum: 0 }
    ApiKey:
      type: object
      required: [id, name, key_prefix, scopes, revoked, created_at]
      properties:
        id: { type: string }
        name: { type: string }
        key_prefix: { type: string }
        scopes:
          type: array
          items: { type: string }
        daily_limit: { type: [integer, "null"] }
        monthly_limit: { type: [integer, "null"] }
        revoked: { type: boolean }
        created_at: { type: integer, description: Unix milliseconds. }
        last_used_at: { type: [integer, "null"], description: Unix milliseconds. }
    CreatedApiKey:
      allOf:
        - $ref: "#/components/schemas/ApiKey"
        - type: object
          required: [api_key]
          properties:
            api_key: { type: string, description: Returned once; never stored. }
    Billing:
      type: object
      required: [enabled, packages]
      properties:
        enabled: { type: boolean }
        credits: { type: [integer, "null"] }
        packages:
          type: array
          items:
            type: object
            required: [id, units]
            properties:
              id: { type: string }
              units: { type: integer, minimum: 1 }
    WebhookEndpoint:
      type: object
      required: [id, url, enabled, created_at]
      properties:
        id: { type: string, format: uuid }
        url: { type: string, format: uri }
        description: { type: [string, "null"] }
        enabled: { type: boolean }
        created_at: { type: integer, description: Unix milliseconds. }

    Status:
      type: string
      description: Public verification status.
      enum: [pending, deliverable, undeliverable, catch_all, unknown]
    Phase:
      type: string
      enum: [queued, checking, waiting_retry, complete]
    Reason:
      type: string
      description: |
        Stable machine-readable classification. Terminal reasons are listed
        here; `last_transient_reason` uses the transient subset (for example
        `greylisted`, `rate_limited`, `dns_temporary_failure`,
        `smtp_temporary_failure`, `server_unreachable`, `tls_failure`,
        `policy_blocked`, `provider_obscured`, `protocol_error`,
        `job_deadline_exceeded`). Clients should tolerate new codes.
      enum:
        - syntax_invalid
        - unsupported_smtputf8
        - unsupported_quoted_local
        - unsupported_address_literal
        - unsupported_address_syntax
        - domain_not_found
        - null_mx
        - no_mail_service
        - recipient_not_found
        - mailbox_disabled
        - smtp_accepted
        - smtp_accept_all
        - attempts_exhausted
        - dns_temporary_failure
        - greylisted
        - rate_limited
        - smtp_temporary_failure
        - provider_obscured
        - policy_blocked
        - source_ip_blocked
        - server_unreachable
        - attempt_timeout
        - tls_failure
        - private_mx_address
        - protocol_error
    RiskFlag:
      type: string
      description: |
        Orthogonal signals that never change the verdict. `possible_typo`
        accompanies `did_you_mean` on an undeliverable domain that is one edit
        (two for domains of ten characters or more) from a major webmail
        provider, and changes neither the verdict nor the confidence score.
      enum: [role_address, disposable_domain, webmail, possible_typo]

    ServiceInfo:
      type: object
      required: [service, version, documentation, mcp, signup, signup_enabled]
      properties:
        service: { type: string, const: email-delivery-check }
        version: { type: string }
        documentation: { type: string, const: /openapi.yaml }
        mcp: { type: string, const: /mcp }
        signup: { type: string, const: /v1/signup }
        signup_enabled: { type: boolean }
        public_url: { type: [string, "null"], format: uri }
        purchase_url: { type: [string, "null"], format: uri }

    CreateVerificationRequest:
      type: object
      additionalProperties: false
      required: [email]
      properties:
        email:
          type: string
          maxLength: 1024
        max_billable_units:
          type: integer
          minimum: 0
          description: Transactional ceiling; the request is not inserted when exceeded.
      example:
        email: person@example.com
    VerifyRequest:
      type: object
      additionalProperties: false
      required: [email]
      properties:
        email:
          type: string
          maxLength: 1024
        wait_seconds:
          type: integer
          minimum: 0
          maximum: 255
          default: 20
          description: Clamped to `1..=SYNC_WAIT_MAX_SECONDS` (default 25, hard cap 28).
        max_billable_units: { type: integer, minimum: 0 }
      example:
        email: person@example.com
        wait_seconds: 20
    CreateBatchRequest:
      type: object
      additionalProperties: false
      required: [emails]
      properties:
        emails:
          type: array
          minItems: 1
          maxItems: 1000
          description: At most `MAX_BATCH_SIZE` items (default 1000, hard cap 10000).
          items:
            type: string
            maxLength: 1024
        max_billable_units: { type: integer, minimum: 0 }
    Submission:
      type: object
      required: [id, status, phase]
      properties:
        id: { type: string, format: uuid }
        status:
          $ref: "#/components/schemas/Status"
        phase:
          $ref: "#/components/schemas/Phase"
    Verification:
      type: object
      required:
        - id
        - email
        - status
        - phase
        - confidence
        - risk_flags
        - smtp_verified
        - credits_charged
        - billing
        - source
        - attempts
        - max_attempts
        - created_at
        - updated_at
      properties:
        id: { type: string, format: uuid }
        email:
          type: string
          description: The address exactly as submitted.
        status:
          $ref: "#/components/schemas/Status"
        phase:
          $ref: "#/components/schemas/Phase"
        confidence:
          type: [integer, "null"]
          minimum: 0
          maximum: 100
          description: |
            Deterministic rubric, `null` while pending: deliverable 97
            (−5 role_address, −20 disposable_domain); catch_all 45 (−15
            role_address, −25 disposable_domain); unknown 15 (−10
            disposable_domain); undeliverable 0. Clamped to 0–100. Not a
            calibrated probability.
        reason:
          $ref: "#/components/schemas/Reason"
        explanation:
          type: string
          description: Safe human-readable explanation derived from the reason code.
        last_transient_reason:
          type: string
          description: The final temporary failure of an exhausted job.
        risk_flags:
          type: array
          items:
            $ref: "#/components/schemas/RiskFlag"
        did_you_mean:
          type: string
          description: |
            Present only for `undeliverable` results with reason
            `domain_not_found` or `no_mail_service` whose domain is one edit
            (two for domains of ten characters or more) from a major webmail
            provider; the address with the corrected domain.
        evidence_strength:
          type: string
          enum: [strong, structural, weak]
        smtp_verified:
          type: boolean
          description: True only for target acceptance plus random-recipient rejection.
        recommended_action:
          type: string
          enum: [send, suppress, send_cautiously, review_or_retry_later]
        credits_charged:
          type: integer
          enum: [0, 1]
          description: |
            Compatibility field: 1 for a charged usable live outcome; 0 while
            pending, for `catch_all`, `unknown`, cached results, parse-level
            `undeliverable` reasons (`syntax_invalid`, `address_too_long`,
            `unsupported_*`), for unlimited tenants, and when the balance was
            already empty at completion.
        billing:
          $ref: "#/components/schemas/VerificationBilling"
        source:
          type: string
          enum: [pending, live, cache]
        checked_at:
          type: string
          format: date-time
          description: Time of the underlying recipient observation; not renewed by cache reads.
        cache_expires_at: { type: string, format: date-time }
        attempts: { type: integer, minimum: 0 }
        max_attempts: { type: integer, minimum: 1 }
        next_attempt_at: { type: string, format: date-time }
        completed_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        evidence:
          $ref: "#/components/schemas/VerificationEvidence"
    VerificationEvidence:
      type: object
      description: Structured evidence only; raw SMTP responses are not exposed. Omitted when nothing was observed.
      required: [tls_used]
      properties:
        mx_host: { type: string }
        smtp_code: { type: integer }
        enhanced_code: { type: string }
        provider: { type: string }
        rule_id: { type: string }
        tls_used: { type: boolean }
        mx_fingerprint: { type: string }
        provider_kind: { type: string, enum: [mailbox, gateway, relay] }
        control_source: { type: string, enum: [live, cached, not_reached] }
        control_observed_at: { type: string, format: date-time }
    VerificationBilling:
      type: object
      required: [billable, units_charged, reason]
      properties:
        billable: { type: boolean }
        units_charged: { type: integer, enum: [0, 1] }
        reason:
          type: string
          enum:
            - deliverable_outcome
            - undeliverable_outcome
            - catch_all_free
            - unknown_free
            - invalid_input_free
            - cached_result_free
    Batch:
      type: object
      required:
        - id
        - total
        - pending
        - deliverable
        - undeliverable
        - catch_all
        - unknown
        - completed
        - conclusive_coverage
        - created_at
        - updated_at
      properties:
        id: { type: string, format: uuid }
        total: { type: integer }
        pending: { type: integer }
        deliverable: { type: integer }
        undeliverable: { type: integer }
        catch_all: { type: integer }
        unknown: { type: integer }
        completed: { type: integer }
        conclusive_coverage:
          type: [number, "null"]
          minimum: 0
          maximum: 1
          description: (deliverable + undeliverable) / completed; catch-all and unknown are not conclusive. `null` until something completed.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    BatchResults:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/Verification"
        next_cursor:
          type: string
          format: uuid
          description: Present when another page exists.

    SignupRequest:
      type: object
      additionalProperties: false
      required: [email]
      properties:
        email:
          type: string
          maxLength: 1024
          description: Must be a supported, non-disposable address; stored normalized.
        name:
          type: string
          maxLength: 120
          description: Defaults to the normalized email address.
    SignupResponse:
      type: object
      required: [tenant_id, api_key, credits]
      properties:
        tenant_id: { type: string, format: uuid }
        api_key:
          type: string
          description: "`edc_` followed by 40 alphanumeric characters. Shown once; only its SHA-256 hash is stored."
        credits: { type: integer }
    Account:
      type: object
      required: [tenant_id, name, credits, unlimited, usage]
      properties:
        tenant_id:
          type: string
          description: UUID for database tenants; a hashed label for `API_KEYS` tenants.
        name: { type: [string, "null"] }
        credits:
          type: [integer, "null"]
          description: Remaining balance; `null` when unlimited.
        unlimited: { type: boolean }
        usage:
          $ref: "#/components/schemas/AccountUsage"
    AccountUsage:
      type: object
      description: Rolling 30-day activity.
      required: [verifications_30d, conclusive_30d, finders_30d, credits_spent_30d]
      properties:
        verifications_30d: { type: integer }
        conclusive_30d:
          type: integer
          description: Verifications that ended `deliverable` or `undeliverable`.
        finders_30d:
          type: integer
          description: Currently always 0; finder usage is visible in the ledger and `credits_spent_30d`.
        credits_spent_30d:
          type: integer
          description: Sum of `verification` and `finder` charges.
    LedgerEntry:
      type: object
      required: [id, delta, balance_after, kind, created_at]
      properties:
        id: { type: string, format: uuid }
        delta:
          type: integer
          description: Positive for grants and purchases, negative for charges and downward adjustments.
        balance_after: { type: integer }
        kind:
          type: string
          enum: [grant, purchase, verification, finder, adjustment, refund]
        reference:
          type: string
          description: "`signup`, a verification or finder id, or the operator's `--note`."
        created_at: { type: string, format: date-time }
    LedgerPage:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/LedgerEntry"
        next_cursor:
          type: string
          description: Opaque cursor, present when another page exists.

    DomainStatus:
      type: string
      enum: [ok, no_mail_service, domain_not_found, unknown]
    LearnedPattern:
      type: object
      required: [pattern, confirmed]
      properties:
        pattern:
          type: string
          description: "For example `{first}.{last}` or `{f}{last}`."
        confirmed:
          type: integer
          description: Number of deliverable finder hits that confirmed the pattern.
    DomainIntel:
      type: object
      required:
        - domain
        - status
        - provider
        - mx_hosts
        - has_mail_service
        - catch_all
        - disposable
        - webmail
        - spf
        - dmarc
        - dmarc_policy
        - email_pattern
        - checked_at
        - provider_kind
        - catch_all_checked_at
      properties:
        domain: { type: string }
        status:
          $ref: "#/components/schemas/DomainStatus"
        provider:
          type: [string, "null"]
          description: Mail provider inferred from the MX hosts (for example `google_workspace`).
        mx_hosts:
          type: array
          items: { type: string }
          description: The advertised MX plan - every exchanger the domain publishes, in preference order, including hosts that did not resolve.
        has_mail_service: { type: boolean }
        catch_all:
          type: [boolean, "null"]
          description: "`null` when no control result is held. Comes from verification history for the same MX set when available, otherwise from one randomized probe, and is `null` again once a later probe contradicted it."
        disposable: { type: boolean }
        webmail: { type: boolean }
        spf:
          type: [boolean, "null"]
          description: "`null` when TXT lookup failed."
        dmarc:
          type: [boolean, "null"]
        dmarc_policy:
          type: [string, "null"]
          description: The DMARC `p=` value when present (for example `none`, `quarantine`, `reject`).
        email_pattern:
          oneOf:
            - $ref: "#/components/schemas/LearnedPattern"
            - type: "null"
        checked_at:
          type: string
          format: date-time
          description: When DNS was observed for this cached record.
        provider_kind:
          type: [string, "null"]
          enum: [mailbox, gateway, relay, null]
          description: Role of the recognised provider; `null` when the provider is unknown.
        catch_all_checked_at:
          type: [string, "null"]
          format: date-time
          description: When the live catch-all control behind `catch_all` ran, if one is still on record; otherwise the DNS refresh that produced the value. `null` when `catch_all` is `null`.

    FinderRequest:
      type: object
      additionalProperties: false
      required: [first_name, domain]
      properties:
        first_name:
          type: string
          maxLength: 200
          description: Must contain at least one letter or digit after accent folding.
        last_name:
          type: string
          maxLength: 200
          default: ""
        domain:
          type: string
          maxLength: 1024
        wait_seconds:
          type: integer
          minimum: 0
          maximum: 255
          default: 20
          description: Clamped to `1..=SYNC_WAIT_MAX_SECONDS`.
      example:
        first_name: Jane
        last_name: Doe
        domain: acme.example
        wait_seconds: 20
    CandidatesRequest:
      type: object
      additionalProperties: false
      required: [first_name, domain]
      properties:
        first_name: { type: string, maxLength: 200 }
        last_name: { type: string, maxLength: 200, default: "" }
        domain: { type: string, maxLength: 1024 }
    FinderStatus:
      type: string
      enum: [pending, found, catch_all, not_found, unknown]
    FinderCandidate:
      type: object
      required: [email, pattern, status]
      properties:
        email: { type: string }
        pattern: { type: string }
        status:
          type: string
          enum: [pending, deliverable, undeliverable, catch_all, unknown, skipped]
        reason:
          type: string
          description: The probe's verification reason, or the domain status for skipped candidates.
    FinderDomain:
      type: object
      required: [domain, provider, catch_all]
      properties:
        domain: { type: string }
        provider: { type: [string, "null"] }
        catch_all: { type: [boolean, "null"] }
    FinderResult:
      type: object
      required:
        - id
        - status
        - email
        - confidence
        - pattern
        - candidates
        - domain
        - credits_charged
        - created_at
        - completed_at
      properties:
        id: { type: string, format: uuid }
        status:
          $ref: "#/components/schemas/FinderStatus"
        email:
          type: [string, "null"]
          description: The deliverable address when `found`; only the best pattern guess when `catch_all`.
        confidence:
          type: [integer, "null"]
          minimum: 0
          maximum: 100
          description: 95 when `found` (97 if the pattern was already confirmed for the domain); 45 + 10 per prior confirmation, capped at 80, when `catch_all`; otherwise `null`.
        pattern:
          type: [string, "null"]
        candidates:
          type: array
          maxItems: 8
          items:
            $ref: "#/components/schemas/FinderCandidate"
        domain:
          $ref: "#/components/schemas/FinderDomain"
        credits_charged:
          type: integer
          enum: [0, 3]
        created_at: { type: string, format: date-time }
        completed_at: { type: [string, "null"], format: date-time }
    FinderSuggestion:
      type: object
      required: [email, pattern, prior]
      properties:
        email: { type: string }
        pattern: { type: string }
        prior:
          type: integer
          minimum: 0
          maximum: 100
          description: Rough global prior of the pattern, in percent.
    CandidateSuggestions:
      type: object
      required: [candidates]
      properties:
        candidates:
          type: array
          maxItems: 8
          items:
            $ref: "#/components/schemas/FinderSuggestion"

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: |
                Stable error code. By status: 400 `invalid_json`,
                `invalid_query`, `invalid_id`, `invalid_idempotency_key`,
                `invalid_cursor`, `invalid_limit`; 401 `unauthorized`;
                402 `insufficient_credits`; 404 `not_found`,
                `signup_disabled`, `tenant_not_found`; 409
                `idempotency_conflict`; 413 `batch_too_large`; 422
                `empty_batch`, `email_input_too_large`, `invalid_email`,
                `disposable_email`, `name_too_long`, `invalid_name`,
                `invalid_domain`, `cost_ceiling_exceeded`,
                `invalid_observed_at`, `invalid_api_key`,
                `invalid_webhook_endpoint`, `unknown_billing_package`; 403
                `insufficient_scope`, `scope_escalation`; 429 `rate_limit_exceeded`,
                `auth_rate_limited`, `signup_rate_limited`; 503
                `service_unavailable`, `worker_not_ready`,
                `billing_not_configured`.
              enum:
                - invalid_json
                - invalid_query
                - invalid_id
                - invalid_idempotency_key
                - invalid_cursor
                - invalid_limit
                - unauthorized
                - insufficient_credits
                - not_found
                - signup_disabled
                - tenant_not_found
                - idempotency_conflict
                - batch_too_large
                - empty_batch
                - email_input_too_large
                - invalid_email
                - disposable_email
                - name_too_long
                - invalid_name
                - invalid_domain
                - cost_ceiling_exceeded
                - invalid_observed_at
                - invalid_api_key
                - invalid_webhook_endpoint
                - unknown_billing_package
                - insufficient_scope
                - scope_escalation
                - api_key_limit_exceeded
                - invalid_stripe_signature
                - invalid_stripe_webhook
                - billing_not_configured
                - stripe_unavailable
                - rate_limit_exceeded
                - auth_rate_limited
                - signup_rate_limited
                - service_unavailable
                - worker_not_ready
            message:
              type: string
              description: Human-readable detail. For `insufficient_credits` it states the credits required, the balance, how many in-flight verifications reserve credits, and `PURCHASE_URL` when configured.
      example:
        error:
          code: insufficient_credits
          message: "this request needs 3 credit(s) but the account balance is 2; add credits and retry"
