{
  "openapi": "3.1.0",
  "info": {
    "title": "Kawaa API",
    "version": "1.0.0",
    "summary": "Email verification, list hygiene and deliverability, over HTTP.",
    "description": "The core Kawaa REST API: verify an address or a list, follow the job, read the results, and check how a domain is set up to send mail.\n\nThis document describes the endpoints a customer integration uses. It is not the whole surface — account administration, billing, team management, integrations and the white-label API are reachable and documented at https://kawaa.com/docs/ but are not modelled here, because a specification is only worth having if every line of it is kept true.\n\n\n\n**API key scopes.** A key can be limited to a list of scopes. Each operation below carries `x-kawaa-scopes` naming every scope it requires — more than one where a route genuinely touches two areas. `x-kawaa-unscoped-only: true` marks an operation a scoped key may not use at all, whatever it holds: it mints a credential, or it acts across every tenant. An operation with neither extension needs no credential. An unrestricted key — which is what every key is unless it was created with scopes — reaches everything. An OAuth access token is always scoped: an operation it can call lists the `OAuth2` scheme in its `security`, with the scopes it requires, and an authenticated operation that does not list it refuses one whatever scopes it holds.\n\n**AI agents.** The same product is served over the Model Context Protocol at `https://api.kawaa.com/mcp`, authenticated with the same API key as an HTTP bearer token, or with an OAuth access token an agent obtains by signing in (the `OAuth2` security scheme). Eleven tools cover verification, bulk jobs, results and deliverability; every tool call is a call to a route described here, so the scopes, rate limits, credit accounting and idempotency below apply to it unchanged. An agent is not a separate entitlement — it is a caller. Setup for Claude Code, Codex, Cursor and Antigravity is at https://kawaa.com/docs/mcp/, and the protocol surface itself is not modelled in this document: this describes the REST API the tools call.",
    "termsOfService": "https://kawaa.com/terms/",
    "contact": {
      "name": "Kawaa support",
      "url": "https://kawaa.com/support/",
      "email": "support@kawaa.com"
    },
    "license": {
      "name": "Proprietary",
      "identifier": "LicenseRef-Kawaa-Terms"
    }
  },
  "servers": [
    {
      "url": "https://api.kawaa.com",
      "description": "Production"
    }
  ],
  "externalDocs": {
    "description": "Guides and reference",
    "url": "https://kawaa.com/docs/"
  },
  "security": [
    {
      "ApiKeyHeader": []
    },
    {
      "BearerToken": []
    }
  ],
  "tags": [
    {
      "name": "Verification",
      "description": "Check whether an address can receive mail."
    },
    {
      "name": "Jobs",
      "description": "Follow asynchronous work and read its results."
    },
    {
      "name": "Search",
      "description": "Find an address, or look at a domain."
    },
    {
      "name": "Activity",
      "description": "Engagement history for an address, where it has been shared."
    },
    {
      "name": "Deliverability",
      "description": "How a domain is set up to send mail."
    },
    {
      "name": "Account",
      "description": "Plan, credits and usage."
    },
    {
      "name": "API keys",
      "description": "The credentials that reach this API."
    },
    {
      "name": "Webhooks",
      "description": "Be told when work finishes instead of polling."
    },
    {
      "name": "Catalogue",
      "description": "Public plan and credit-pack prices."
    },
    {
      "name": "Health",
      "description": "Is the API up."
    }
  ],
  "paths": {
    "/health": {
      "get": {
        "tags": [
          "Health"
        ],
        "operationId": "getHealth",
        "summary": "Liveness probe",
        "description": "Answers without authentication. Not a status page — https://status.kawaa.com reports component health.",
        "security": [],
        "responses": {
          "200": {
            "description": "The API is serving — **read `status` in the body before concluding it is healthy**. A degraded dependency does not always change the status code: a worker dead-letter-queue probe that fails or reads above its threshold sets `status: \"degraded\"` while leaving `healthy: true`, and the response is still 200. A monitor that watches only the status code misses worker degradation entirely.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "examples": [
                        "healthy"
                      ]
                    },
                    "timestamp": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "service": {
                      "type": "string"
                    },
                    "version": {
                      "type": "string"
                    }
                  },
                  "required": [
                    "status",
                    "timestamp",
                    "service",
                    "version"
                  ]
                }
              }
            }
          },
          "503": {
            "description": "A dependency probe failed. The SAME JSON shape as 200, with `status: \"degraded\"` — a monitor that treats only 200 as a valid response stops reading precisely when the endpoint has something to say.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "const": "degraded",
                      "description": "Always `degraded` on a 503 — this response exists to report one.",
                      "examples": [
                        "degraded"
                      ]
                    },
                    "timestamp": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "service": {
                      "type": "string"
                    },
                    "version": {
                      "type": "string"
                    }
                  },
                  "required": [
                    "status",
                    "timestamp",
                    "service",
                    "version"
                  ]
                }
              }
            }
          }
        }
      }
    },
    "/v1/verify": {
      "post": {
        "tags": [
          "Verification"
        ],
        "operationId": "verifyEmail",
        "summary": "Verify one address",
        "description": "Verifies a single address and answers with the verdict. Charges 1 credit, or 0.5 when the result is served from cache.\n\nIf verification does not finish inside the request window the response is still `200`, with `status: \"pending\"` and a `job_id` instead of a result — poll `GET /v1/jobs/{id}` rather than calling this again, which would charge again.\n\nSend `Idempotency-Key` to make a retry safe.",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/VerifyRequest"
              },
              "examples": {
                "simple": {
                  "summary": "Just the address",
                  "value": {
                    "email": "user@example.com"
                  }
                },
                "deep": {
                  "summary": "With options",
                  "value": {
                    "email": "user@example.com",
                    "options": {
                      "deep_verify": true,
                      "include_ai": true
                    }
                  }
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "BearerToken": []
          },
          {
            "OAuth2": [
              "verification:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "A verdict, or `status: \"pending\"` when the check is still running.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "oneOf": [
                        {
                          "$ref": "#/components/schemas/VerificationResult"
                        },
                        {
                          "$ref": "#/components/schemas/PendingVerification"
                        }
                      ]
                    }
                  },
                  "required": [
                    "success",
                    "data"
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/InsufficientCredits"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/IdempotencyConflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          },
          "500": {
            "description": "Something failed after the request was accepted, and **the message says what that means for the money**. A keyed dispatch whose lease could not be confirmed asks you to retry with the SAME `Idempotency-Key`: the work may already be running, and a fresh key buys it again. A batch that was charged and could not create its job attempts a refund and says whether that succeeded. Do not retry blind, and do not assume the debit was restored — read `error.message`, and check `GET /v1/jobs` before resubmitting.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        },
        "x-kawaa-scopes": [
          "verification:write"
        ]
      }
    },
    "/v1/verify/batch": {
      "post": {
        "tags": [
          "Verification"
        ],
        "operationId": "verifyEmailBatch",
        "summary": "Verify a list of addresses",
        "description": "Submits up to 10,000 addresses and answers immediately with a `job_id`. Charges about 1 credit per address at the moment the job is accepted, not when it finishes.\n\nFollow it with `GET /v1/jobs/{id}`, or supply `webhook_url` and be told. Send `Idempotency-Key` to make a retry safe.\n\n`options.timeout_ms` is refused: it bounds the synchronous wait on `POST /v1/verify` and nothing on this path honours it.",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/VerifyBatchRequest"
              },
              "examples": {
                "poll": {
                  "summary": "Poll for the result",
                  "value": {
                    "emails": [
                      "a@example.com",
                      "b@example.com"
                    ]
                  }
                },
                "webhook": {
                  "summary": "Be called when it finishes",
                  "value": {
                    "emails": [
                      "a@example.com"
                    ],
                    "webhook_url": "https://example.com/hooks/kawaa"
                  }
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "BearerToken": []
          },
          {
            "OAuth2": [
              "verification:write"
            ]
          }
        ],
        "responses": {
          "202": {
            "description": "Accepted and charged. The verdicts arrive on the job.\n\n**A replay is also a 202, and it charged nothing.** Repeating the submission under the same `Idempotency-Key` returns the ORIGINAL job with `idempotent_replay: true` and `credits_used: 0`. Read those two fields before reconciling a debit from this status: the 202 alone does not distinguish a new paid job from a recovered one.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "$ref": "#/components/schemas/BatchAccepted"
                    }
                  },
                  "required": [
                    "success",
                    "data"
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/InsufficientCredits"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/IdempotencyConflict"
          },
          "413": {
            "$ref": "#/components/responses/PayloadTooLarge"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          },
          "500": {
            "description": "Something failed after the request was accepted, and **the message says what that means for the money**. A keyed dispatch whose lease could not be confirmed asks you to retry with the SAME `Idempotency-Key`: the work may already be running, and a fresh key buys it again. A batch that was charged and could not create its job attempts a refund and says whether that succeeded. Do not retry blind, and do not assume the debit was restored — read `error.message`, and check `GET /v1/jobs` before resubmitting.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        },
        "x-kawaa-scopes": [
          "verification:write"
        ]
      }
    },
    "/v1/verify/file": {
      "post": {
        "tags": [
          "Verification"
        ],
        "operationId": "verifyFile",
        "summary": "Verify a CSV or TXT upload",
        "description": "Two steps. `action: \"get_upload_url\"` returns a presigned URL and a `file_key`; PUT the file to that URL, then call again with `action: \"process\"` and the `file_key`.\n\n**CSV and TXT only.** An `.xlsx` filename is refused at the handshake with `400 Only CSV and TXT files are supported`, before any upload.\n\n**What `process` charges: one credit per address**, counted after invalid entries and duplicates are removed, and debited up front — a 50,000-row file with 9,300 distinct valid addresses is a 9,300-credit debit before any verification starts. Check the balance first; the plan's batch ceiling also applies. An address that definitively fails to enqueue is refunded; one whose dispatch is ambiguous is reported in `enqueue_unknown` with its credits in `credits_held` until reconciliation settles it.\n\nThe `process` action accepts `Idempotency-Key`. Any field the chosen action does not read is a 400 rather than silently ignored — including `webhook_secret`, which this endpoint does NOT support: a file-completion callback is delivered **unsigned**, so treat its body as a hint to go and read `GET /v1/jobs/{job_id}` rather than as evidence. `POST /v1/verify/batch` does support a signed callback.",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "oneOf": [
                  {
                    "$ref": "#/components/schemas/FileUploadUrlRequest"
                  },
                  {
                    "$ref": "#/components/schemas/FileProcessRequest"
                  }
                ]
              }
            }
          }
        },
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "BearerToken": []
          },
          {
            "OAuth2": [
              "verification:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "A presigned upload URL and the key to submit with it (for `get_upload_url`). Nothing is charged by this step.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "upload_url": {
                          "type": "string",
                          "format": "uri",
                          "description": "PUT the file here, unchanged, with no extra headers. It is a presigned S3 URL: anyone holding it can write that one object, so treat it as a credential."
                        },
                        "file_key": {
                          "type": "string",
                          "description": "Pass this back as `file_key` on the `process` call. It is scoped to your account — a key from another account is refused with 403.",
                          "examples": [
                            "uploads/usr_123/2026-09-17/list.csv"
                          ]
                        },
                        "expires_in_seconds": {
                          "type": "integer",
                          "description": "How long `upload_url` stays valid. Ask for a new one rather than retrying an expired PUT.",
                          "examples": [
                            900
                          ]
                        },
                        "next_step": {
                          "type": "string",
                          "description": "The same instruction in prose, for a caller reading the response rather than this document."
                        }
                      },
                      "required": [
                        "upload_url",
                        "file_key",
                        "expires_in_seconds"
                      ]
                    }
                  },
                  "required": [
                    "success",
                    "data"
                  ]
                }
              }
            }
          },
          "202": {
            "description": "The file was accepted and charged (for `process`). Poll `GET /v1/jobs/{job_id}`; the counts here describe what was accepted, not what was verified.\n\n**A replay is also a 202, and it charged nothing.** Repeating the submission under the same `Idempotency-Key` returns the ORIGINAL job with `idempotent_replay: true` and `credits_used: 0`. Read those two fields before reconciling a debit from this status: the 202 alone does not distinguish a new paid job from a recovered one.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "Retry-After": {
                "description": "Present with `dispatch_in_progress`: how long to wait before retrying the identical request.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "job_id": {
                          "type": "string",
                          "description": "The job to poll. The only way back to work that has been charged for."
                        },
                        "status": {
                          "type": "string",
                          "description": "Where the job starts: `processing`, or `enqueue_unknown` when some addresses' dispatch could not be confirmed."
                        },
                        "total_submitted": {
                          "type": "integer",
                          "description": "Rows read from the file, including the invalid ones."
                        },
                        "duplicates_removed": {
                          "type": "integer"
                        },
                        "invalid_emails_skipped": {
                          "type": "integer",
                          "description": "Rows that were not addresses. Skipped, not charged for, and not in the job."
                        },
                        "unique_emails": {
                          "type": "integer",
                          "description": "Addresses the job will verify — what `credits_used` was charged for, except where dispatch was ambiguous (see `credits_held`)."
                        },
                        "credits_used": {
                          "type": "number",
                          "description": "Debited when the file was accepted, before any verification ran."
                        },
                        "credits_remaining": {
                          "type": "number",
                          "description": "Omitted when the balance could not be read. Its absence is not a zero balance."
                        },
                        "credits_refunded": {
                          "type": "number",
                          "description": "Returned for addresses that definitively failed to enqueue."
                        },
                        "credits_held": {
                          "type": "number",
                          "description": "Charged but not yet settled: addresses whose dispatch is unconfirmed, pending reconciliation. Neither spent nor refunded yet."
                        },
                        "enqueue_unknown": {
                          "type": "integer",
                          "description": "Addresses whose dispatch could not be confirmed. They may or may not be verified; `credits_held` covers them until reconciliation decides."
                        },
                        "dispatch_in_progress": {
                          "type": "boolean",
                          "description": "A request under this same `Idempotency-Key` is still being dispatched. Nothing more was charged. Retry after `Retry-After`, or just poll the job."
                        },
                        "dispatch_settlement_pending": {
                          "type": "boolean",
                          "description": "Dispatch finished but its credit adjustment has not settled. Nothing more was charged."
                        },
                        "idempotent_replay": {
                          "type": "boolean",
                          "description": "This returned an earlier submission under the same key and charged nothing."
                        },
                        "message": {
                          "type": "string",
                          "description": "Prose for whichever of the above happened."
                        }
                      },
                      "required": [
                        "job_id",
                        "status",
                        "unique_emails",
                        "credits_used"
                      ]
                    }
                  },
                  "required": [
                    "success",
                    "data"
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/InsufficientCredits"
          },
          "409": {
            "$ref": "#/components/responses/IdempotencyConflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "413": {
            "description": "The file holds more addresses than the plan allows in one job. Nothing was charged. Split the file and submit the parts as separate jobs.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "503": {
            "description": "The retry protection for this `Idempotency-Key` could not be resolved — its receipt was unreadable, a concurrent attempt is still confirming, or an earlier attempt was refunded. Nothing was charged for this request. **Retry with the SAME key**: a fresh one starts a new paid job.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "500": {
            "description": "Something failed after the file was charged for, and **the message says what that means for the money**. A keyed attempt that could not be recovered asks you to retry with the SAME `Idempotency-Key`; a job that could not be created after the debit says explicitly whether the refund succeeded. Do not retry with a fresh key, and do not assume the debit was restored — read `error.message` and check `GET /v1/jobs`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "404": {
            "description": "`action: \"process\"` named a `file_key` with no object behind it — the presigned PUT was never completed, or the upload failed. Recoverable and free: nothing was charged, because the charge happens after the file is read. Get a fresh URL with `action: \"get_upload_url\"`, upload again, and process the new key. Distinct from the 500 below, which happens AFTER the debit.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        },
        "x-kawaa-scopes": [
          "verification:write"
        ]
      }
    },
    "/v1/jobs": {
      "get": {
        "tags": [
          "Jobs"
        ],
        "operationId": "listJobs",
        "summary": "List verification jobs",
        "description": "Newest first. `total` counts every job matching the listing, not just the page. Paginate on `has_more` and `next_offset`.\n\n`total_is_approximate: true` means the count stopped at its scan bound, so `total` is a floor.",
        "parameters": [
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "name": "status",
            "in": "query",
            "description": "Return only jobs in this state. `cancelled` is a real equality filter like any other value — it returns nothing today because nothing produces that status, not because the filter is inert, so a row that carried it would be matched and returned (#1321).",
            "schema": {
              "type": "string",
              "enum": [
                "pending",
                "processing",
                "completed",
                "failed",
                "cancelled",
                "enqueue_unknown"
              ]
            }
          },
          {
            "name": "offset",
            "in": "query",
            "description": "The `next_offset` from the previous page. Opaque — do not construct one.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "BearerToken": []
          },
          {
            "OAuth2": [
              "verification:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "A page of jobs.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "$ref": "#/components/schemas/JobPage"
                    }
                  },
                  "required": [
                    "success",
                    "data"
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "x-kawaa-scopes": [
          "verification:read"
        ]
      }
    },
    "/v1/jobs/{id}": {
      "get": {
        "tags": [
          "Jobs"
        ],
        "operationId": "getJob",
        "summary": "Job progress and results",
        "description": "Progress counts, and the per-address results when the job has produced any.\n\nPass `include_results=false` while polling: dragging a 10,000-row result set through every poll to learn a percentage is expensive for both sides. Results page with `limit` and `next_key`.",
        "parameters": [
          {
            "$ref": "#/components/parameters/JobId"
          },
          {
            "name": "include_results",
            "in": "query",
            "description": "Set to the exact string `false` to leave the results array out. **Only that exact string**: `handleGetJob` computes `include_results !== 'false'`, so `0`, `no`, `FALSE` and anything else all INCLUDE the results. No enum is declared here for that reason — a two-value one made generated clients refuse requests the API accepts and handles exactly as described.",
            "schema": {
              "type": "string",
              "default": "true"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "description": "Results per page, up to 1000. **Clamped, not refused**: `handleGetJob` checks the digits with `/^[1-9]\\d*$/` and then applies `Math.min(value, 1000)`, so `limit=1001` returns a normal page of 1,000. No `maximum` is declared for that reason — it would refuse locally a request the route handles. A zero, a negative or a non-numeric value IS refused, because it fails the digit check first.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "default": 100
            }
          },
          {
            "name": "next_key",
            "in": "query",
            "description": "The `next_key` from the previous page. Opaque.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "BearerToken": []
          },
          {
            "OAuth2": [
              "verification:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The job.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "$ref": "#/components/schemas/Job"
                    }
                  },
                  "required": [
                    "success",
                    "data"
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "x-kawaa-scopes": [
          "verification:read"
        ]
      }
    },
    "/v1/jobs/{id}/download": {
      "get": {
        "tags": [
          "Jobs"
        ],
        "operationId": "downloadJobResults",
        "summary": "Download a job's results",
        "description": "The whole result set as CSV or JSON, optionally filtered to one status. A large result set is answered with a short-lived presigned URL rather than inline.\n\n**Check `complete` before treating the file as the job's results.** The export is read from an eventually-consistent index, so a job that has just flipped to `completed` can be exported before its last rows are visible. Every JSON response here — the inline one and the presigned-link one — carries `complete`, `expected_rows` and `rows_missing`, measured against the job's own `processed_emails`. A short JSON export is reported, not refused: the index normally catches up within seconds, so request it again (#1339).\n\n**A `format=csv` export that is known to be short is refused with `409` (`retryable: true`), not served.** The bytes of a CSV have nowhere to carry the verdict, and a file that looks complete and is not is worse than no file. The refusal names the shortfall and offers `format=json`, which returns the rows that do exist together with the shortfall, inline. Note the size of a *filtered* export does not bound this: a selective filter on a large job can produce a handful of rows from a read that was badly short.\n\n**An empty read is not automatically a 404.** If the job counts addresses it finished and none of their rows are readable, the route answers `409` with `retryable: true` rather than `404 No results found for this job`. Retrying is the right next action and also distinguishes the two possible causes — the index catching up, which clears in seconds, or the rows having passed the plan's data-retention window, in which case they are gone. A job that genuinely produced no rows still answers 404.\n\nOne known gap: the presigned URL names an object key, not a version, so two overlapping requests for the same export can each report their own read's completeness while both serve whichever object was written last. Binding the version needs an IAM grant the Lambda role does not hold; tracked in #1382.",
        "parameters": [
          {
            "$ref": "#/components/parameters/JobId"
          },
          {
            "name": "format",
            "in": "query",
            "schema": {
              "type": "string",
              "pattern": "^(?:[Cc][Ss][Vv]|[Jj][Ss][Oo][Nn])$",
              "default": "csv"
            },
            "description": "Case-insensitive: the handler lower-cases the query value before validating it, so `CSV` and `csv` are the same request. Expressed as a pattern rather than an enum, which can only list casings. Accepted values: `csv`, `json`."
          },
          {
            "name": "filter",
            "in": "query",
            "description": "Export only part of the job. FOUR OF THESE ARE SUMMARY BUCKETS, not exact verdicts, because they mirror the counts in the job summary: `invalid` also returns disposable addresses; `risky` also returns catch_all, role and spam_trap; `unknown` also returns rows with no stored status at all. `valid` is exact, and so are the finer four — `catch_all`, `disposable`, `role` and `spam_trap` each return only themselves. Building a suppression list from `invalid` therefore gets the disposable addresses too, which is usually what is wanted; ask for the exact verdict when it is not. `all` is accepted and is the explicit no-op — the handler treats it as \"no filter\" and names it in the 400 it returns for anything else, so it is spelled out here rather than left to `VerificationStatus`, which does not contain it. Case-insensitive: the handler lower-cases the query value before validating it, so `ALL` and `all` are the same request. Expressed as a pattern rather than an enum, which can only list casings. Accepted values: `all`, `valid`, `invalid`, `risky`, `unknown`, `catch_all`, `disposable`, `role`, `spam_trap`.",
            "schema": {
              "type": "string",
              "pattern": "^(?:[Aa][Ll][Ll]|[Vv][Aa][Ll][Ii][Dd]|[Ii][Nn][Vv][Aa][Ll][Ii][Dd]|[Rr][Ii][Ss][Kk][Yy]|[Uu][Nn][Kk][Nn][Oo][Ww][Nn]|[Cc][Aa][Tt][Cc][Hh]_[Aa][Ll][Ll]|[Dd][Ii][Ss][Pp][Oo][Ss][Aa][Bb][Ll][Ee]|[Rr][Oo][Ll][Ee]|[Ss][Pp][Aa][Mm]_[Tt][Rr][Aa][Pp])$",
              "default": "all"
            }
          }
        ],
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "BearerToken": []
          },
          {
            "OAuth2": [
              "verification:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The rows themselves, or a link to them when the set is large. Neither form is wrapped in the standard `{success, data}` envelope — a deserializer expecting one rejects every successful download. `format=csv` answers with `text/csv`, not JSON.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/JobResultsInline"
                    },
                    {
                      "$ref": "#/components/schemas/JobResultsLink"
                    }
                  ]
                }
              },
              "text/csv": {
                "schema": {
                  "type": "string",
                  "description": "Raw CSV, with a Content-Disposition attachment filename."
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "The export could not be given to you as asked, for one of two reasons — both `retryable: true`, and the message says which.\n\n**The job counts results and none of their rows are readable.** Either the results index has not caught up, which clears within seconds, or the rows have passed the data-retention window for the job's plan, in which case they are gone and retrying will keep returning this. The route cannot tell those apart; retrying distinguishes them at no cost, which is why it is marked retryable — but do **not** present this to a user as \"nothing was lost\".\n\n**A `format=csv` export is known to be short.** A CSV body has nowhere to report a shortfall, so it is refused rather than handed over looking complete. The message names the missing row count; ask for `format=json` to read the rows that do exist right now, with the shortfall alongside.\n\nNothing is charged for either (#1339).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "x-kawaa-scopes": [
          "verification:read"
        ]
      }
    },
    "/v1/find": {
      "post": {
        "tags": [
          "Search"
        ],
        "operationId": "findEmail",
        "summary": "Find a business address",
        "description": "Works out a person's work address from their name and their company's mail domain, then verifies it. Charges 5 credits.\n\n`status` is the verdict on the address it settled on: `valid`, `risky`, `role`, `unknown`, `catch_all`, `not_found` or `invalid_name`. A `catch_all` outcome still charges, and it is INCONCLUSIVE rather than negative: the domain accepts every recipient, so no address could be confirmed either way. It may come back with a best-guess `email` (unverified) or with `email: null` — `findEmailForPerson` returns both shapes — so branch on `status`, never on whether `email` is present.\n\nA name over 64 characters is a 400 before the charge: RFC 5321 caps a local part at 64 octets, so no candidate could exist.\n\n**A `catch_all` status does not guarantee an address.** When the domain accepts every recipient and no candidate clears the confidence threshold, the paid result comes back with `email: null` and `confidence: 0` — you have bought the search and learned that no address can be confirmed.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/FindRequest"
              },
              "examples": {
                "basic": {
                  "value": {
                    "first_name": "Ada",
                    "last_name": "Lovelace",
                    "domain": "example.com"
                  }
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "BearerToken": []
          },
          {
            "OAuth2": [
              "verification:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The search. Branch on `status`, not on whether `email` is present.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "first_name": {
                          "type": "string"
                        },
                        "last_name": {
                          "type": "string"
                        },
                        "email": {
                          "type": [
                            "string",
                            "null"
                          ],
                          "format": "email",
                          "description": "The address settled on, or `null`. Null is NOT the same as 'no address exists' — on a catch-all domain nothing could be confirmed either way."
                        },
                        "confidence": {
                          "type": "number",
                          "minimum": 0,
                          "maximum": 100
                        },
                        "pattern": {
                          "type": [
                            "string",
                            "null"
                          ],
                          "description": "The naming pattern the domain appears to use, e.g. `first.last`."
                        },
                        "verified": {
                          "type": "boolean",
                          "description": "False on a catch-all result even when `email` is present: the server accepts everything, so nothing was confirmed."
                        },
                        "status": {
                          "type": "string",
                          "examples": [
                            "valid",
                            "risky",
                            "role",
                            "unknown",
                            "catch_all",
                            "not_found",
                            "invalid_name"
                          ],
                          "description": "The verdict. `catch_all` is inconclusive rather than negative, and `invalid_name` means no candidate could be generated at all."
                        },
                        "message": {
                          "type": "string"
                        },
                        "credits_used": {
                          "type": "number",
                          "description": "5 for a search. Still 5 on a de-duplicated repeat — read `idempotent_replay` to tell a second charge from a free one."
                        },
                        "credits_remaining": {
                          "type": "number"
                        },
                        "idempotent_replay": {
                          "type": "boolean",
                          "description": "Present and true when this repeated an identical search already charged for, and therefore charged nothing. This route takes no `Idempotency-Key`: it de-duplicates against a fixed two-minute bucket."
                        }
                      },
                      "required": [
                        "first_name",
                        "last_name",
                        "email",
                        "confidence",
                        "status",
                        "credits_used"
                      ]
                    }
                  },
                  "required": [
                    "success",
                    "data"
                  ]
                }
              }
            }
          },
          "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": "The credit debit could not be completed because the balance was being changed concurrently. **Nothing was charged** — this is contention, not a refusal. Retry; a refusal for lack of credits is `402`, which is permanent until the balance changes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "500": {
            "description": "The search failed somewhere the handler could not classify. Its outer catch covers the cached pattern lookup, the search itself and the credit debit, so **the billing state is genuinely unknown**: an exception during the debit can leave the charge applied with no answer to show for it.\n\nRetry the SAME search rather than a different one. `/v1/find` takes no `Idempotency-Key`, but it de-duplicates an identical first name, last name and domain within a fixed two-minute bucket, so a prompt repeat is recovered by that window rather than charged again — and the answer will carry `idempotent_replay: true` if it was. A repeat after the window is a second paid search. Check `GET /v1/usage` before waiting.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        },
        "x-kawaa-scopes": [
          "verification:write"
        ]
      }
    },
    "/v1/find/bulk": {
      "post": {
        "tags": [
          "Search"
        ],
        "operationId": "findEmailBulk",
        "summary": "Find business addresses for several people",
        "description": "The same lookup for several people at one domain. Retry-safe for FIFTEEN MINUTES: the receipt a completed lookup writes has a 15-minute TTL, so a repeat inside that window replays the summary — rather than the individual results — and charges nothing, and a repeat after it is a new paid lookup. A delayed retry is not free.\n\n**What it charges, before it starts.** 5 credits per person — the single finder's price — multiplied by the number of people whose names are usable, debited up front. Up to 50 people per request, so up to **250 credits** on one call.\n\n\"Usable\" is the same rule as `POST /v1/find`: both names must contain at least one letter after normalization (lower-case, accents stripped, only `a-z` kept). A person who fails it comes back as an `invalid_name` row and is **not** charged for, so the debit is 5 x the chargeable count rather than 5 x the list length. Count the usable names yourself before submitting if the difference matters — nothing in the request tells you which rows will be free.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/FindBulkRequest"
              }
            }
          }
        },
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "BearerToken": []
          },
          {
            "OAuth2": [
              "verification:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The searches that were run. **Not necessarily one result per person**: when the request approaches the gateway deadline the handler stops, refunds the chargeable tail it did not process, and still answers 200 with `processed` below `total` and a shorter `results` array. Compare `processed` with `total` before treating a missing person as not found, and read `credits_used` for what was actually charged after the refund.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "domain": {
                          "type": "string",
                          "description": "The domain the searches ran against, normalized (lower-cased, one leading `www.` stripped)."
                        },
                        "pattern": {
                          "type": [
                            "string",
                            "null"
                          ],
                          "description": "The address pattern inferred for this domain, when one was."
                        },
                        "results": {
                          "type": "array",
                          "description": "One entry per person PROCESSED — see the description above; a person absent from this array was not searched for, which is not the same as not found.",
                          "items": {
                            "type": "object",
                            "required": [
                              "first_name",
                              "last_name",
                              "email",
                              "confidence",
                              "pattern",
                              "verified",
                              "status"
                            ],
                            "properties": {
                              "first_name": {
                                "type": "string"
                              },
                              "last_name": {
                                "type": "string"
                              },
                              "email": {
                                "type": [
                                  "string",
                                  "null"
                                ]
                              },
                              "confidence": {
                                "type": "number"
                              },
                              "pattern": {
                                "type": [
                                  "string",
                                  "null"
                                ]
                              },
                              "verified": {
                                "type": "boolean"
                              },
                              "status": {
                                "type": "string",
                                "description": "`catch_all` is inconclusive rather than negative: the domain accepts every recipient, so nothing could be confirmed either way."
                              },
                              "message": {
                                "type": "string"
                              }
                            }
                          }
                        },
                        "total": {
                          "type": "integer",
                          "description": "People in the request."
                        },
                        "processed": {
                          "type": "integer",
                          "description": "People actually searched for. Below `total` means the run stopped early and the rest were refunded."
                        },
                        "found": {
                          "type": "integer",
                          "description": "How many produced an address."
                        },
                        "credits_used": {
                          "type": "number",
                          "description": "What was charged after any refund for an unprocessed tail — not 5 × `total`. **On `replayed: true` this is the ORIGINAL request's cost, not what this call charged** — a replay within the 15-minute window charges nothing and returns the first response's summary verbatim. Summing this across responses without checking `replayed` counts the same debit twice."
                        },
                        "credits_remaining": {
                          "type": "number"
                        },
                        "replayed": {
                          "type": "boolean",
                          "description": "Present and true when this returned an earlier identical request's summary under the same retry protection. `results_available: false` accompanies it: the counts replay, the individual rows do not."
                        },
                        "results_available": {
                          "type": "boolean",
                          "description": "False on a replay, where only the summary was kept."
                        },
                        "message": {
                          "type": "string"
                        }
                      },
                      "required": [
                        "domain",
                        "results",
                        "total",
                        "processed",
                        "found",
                        "credits_used"
                      ]
                    }
                  },
                  "required": [
                    "success",
                    "data"
                  ]
                }
              }
            }
          },
          "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"
          },
          "409": {
            "description": "A matching request is already using this retry key, or one is being reset. Nothing was charged and nothing was started. Wait and retry the identical request — it is the retry protection working, not a rejection of the payload.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "503": {
            "description": "The retry-protection record could not be read, a credit reservation for a matching request is still confirming, or the search finished but could not be finalized safely. Retry the identical request rather than a new one: these are the states where a fresh payload risks buying the same search twice.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "500": {
            "description": "The bulk search failed after it was charged for. Several branches answer this — job setup, processing, the refund of an unprocessed tail, and finalizing the retry protection — and some of them say outright that credits could not be refunded and need reconciliation. **Money may still be debited.** Read the message, check `GET /v1/usage`, and do not send the same payload again until you know which happened.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        },
        "x-kawaa-scopes": [
          "verification:write"
        ]
      }
    },
    "/v1/domain/{domain}": {
      "get": {
        "tags": [
          "Search"
        ],
        "operationId": "searchDomain",
        "summary": "Look at a domain",
        "description": "Mail servers, catch-all behaviour and the addresses Kawaa can see for a domain.\n\n**This GET starts work and charges.** The first call for an uncached domain claims the domain, deducts **10 credits**, enqueues every candidate to the verification worker, and returns immediately with `status: \"processing\"` and an **empty `emails` array**. That empty array is not the answer — it means the search has just begun.\n\nRepeat the same GET to poll. Each poll assembles whatever the worker has finished; when every candidate resolves, the domain turns out to be catch-all, or the job ages out, it returns `status: \"completed\"` and is cached for 7 days. **Polling never charges again** — the owner paid at the first call — and a search served from that 7-day cache charges nothing at all: `serveCompletedCache` answers `credits_used: 0` for the owner and for anyone else.\n\nTreating the first response as the finished result is the mistake this endpoint invites: you have paid for a search and read none of it.",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "examples": [
                "example.com"
              ]
            },
            "description": "A bare registrable domain name. **Normalized before it is validated**, which is why no `format: hostname` or `pattern` is declared here: `normalizeDomain` trims, lower-cases, punycodes an internationalized domain and then strips ONE leading `www.` — in that order, because IDNA maps the Unicode label separators (`。`, `．`, `｡`) to `.`, so `www。münchen.de` only grows its ASCII dot during the fold. Only then does `isValidDomainFormat` run. So `\" WWW.Example.COM \"` and `münchen.de` are both accepted — the latter is checked as `xn--mnchen-3ya.de`, the same fold `/v1/verify` applies to the domain half of an address (#1332). A scheme, a path or a port is NOT stripped and is a `400 Invalid domain format`; send the host on its own. At least two labels are required, each label 1–63 characters, and the TLD either letters only and at least two (`com`, `de`) or a decodable `xn--` A-label of an internationalized one (`пример.рф` is checked as `xn--e1afmkfd.xn--p1ai`). The A-label must decode AND be the canonical spelling of what it decodes to, not merely carry the prefix: `example.xn--abc` decodes to nothing, and `example.xn---7a` decodes to `¡` whose one canonical form is `xn--7a`. Neither can name a TLD any root zone holds, so both are refused rather than charged for."
          }
        ],
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "BearerToken": []
          },
          {
            "OAuth2": [
              "verification:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The search, in whichever state it is in. `status` is the field to branch on: `processing` means the work has started and `emails` is empty because nothing has finished yet, NOT that the domain has no addresses.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "domain": {
                          "type": "string",
                          "description": "The domain as normalized: lower-cased, one leading `www.` stripped."
                        },
                        "status": {
                          "type": "string",
                          "enum": [
                            "processing",
                            "completed"
                          ],
                          "description": "`processing`: the work is under way and `emails` is EMPTY BECAUSE NOTHING HAS FINISHED. Repeat the same GET to poll — polling is free. `completed`: this is the answer."
                        },
                        "emails": {
                          "type": "array",
                          "description": "Addresses found, and empty until `status` is `completed`. Also empty on a completed search of a catch-all domain, where no individual address could be confirmed — read `catch_all_domain` before concluding there are none.",
                          "items": {
                            "type": "object",
                            "description": "One discovered address. This is `mapFound`'s shape in `lambda/domain-search/index.ts` — it carries no `confidence`; `quality_score` is the number to sort or threshold on.",
                            "properties": {
                              "email": {
                                "type": "string",
                                "format": "email"
                              },
                              "status": {
                                "$ref": "#/components/schemas/VerificationStatus"
                              },
                              "quality_score": {
                                "type": "integer",
                                "description": "The verification quality score for this address, 0-100. Rows are returned sorted by it. `0` where the stored verification carried none — not a measurement of nothing."
                              },
                              "is_role_account": {
                                "type": "boolean",
                                "description": "An address that reaches a function rather than a person (`info@`, `support@`). True when the verification flagged it OR the status is `role`. Most common-prefix hits on a small domain are these, which is why it is on every row rather than left to be inferred from the local part."
                              },
                              "is_catch_all": {
                                "type": "boolean",
                                "description": "The mailbox could not be confirmed because the domain accepts every recipient. NOT a negative result, and not the same as `catch_all_domain`, which says the whole search ended that way."
                              },
                              "from_cache": {
                                "type": "boolean",
                                "description": "This row came out of the verification cache rather than being probed during this search. Per row, and distinct from the response-level `from_cache`, which is about the whole completed search."
                              }
                            },
                            "required": [
                              "email",
                              "status",
                              "quality_score",
                              "is_role_account",
                              "is_catch_all",
                              "from_cache"
                            ]
                          }
                        },
                        "email_count": {
                          "type": "integer",
                          "description": "`emails.length`, for a client that only needs the count."
                        },
                        "job_id": {
                          "type": "string",
                          "description": "The search this poll belongs to. Present while work is in flight."
                        },
                        "progress": {
                          "type": "object",
                          "description": "How far the search has got. `percent` is capped at 99 until the search completes, so 100 only ever means finished.",
                          "properties": {
                            "checked": {
                              "type": "integer"
                            },
                            "total": {
                              "type": "integer"
                            },
                            "percent": {
                              "type": "integer"
                            }
                          }
                        },
                        "progress_percent": {
                          "type": "integer",
                          "description": "The same number as `progress.percent`, flattened."
                        },
                        "patterns_checked": {
                          "type": "integer",
                          "description": "Candidate addresses resolved. Present once the search completes."
                        },
                        "completion_reason": {
                          "type": "string",
                          "enum": [
                            "all_candidates_checked",
                            "time_limit_reached",
                            "catch_all_domain"
                          ],
                          "description": "Why the search stopped. `time_limit_reached` means the answer is partial — fewer candidates were resolved than exist. `catch_all_domain` means no individual address could be confirmed at all."
                        },
                        "catch_all_domain": {
                          "type": "boolean",
                          "description": "True when the domain accepts every recipient, so an empty `emails` says nothing about which addresses exist."
                        },
                        "from_cache": {
                          "type": "boolean",
                          "description": "True when this came from the 7-day completed cache. Free, for the owner and for anyone else."
                        },
                        "credits_used": {
                          "type": "number",
                          "description": "10 on the call that starts the search, 0 on every poll and on a cache hit. Read it rather than assuming."
                        },
                        "credits_remaining": {
                          "type": "number"
                        },
                        "message": {
                          "type": "string",
                          "description": "Prose for the state above, including why a search stopped early."
                        },
                        "cached_at": {
                          "type": "string",
                          "format": "date-time",
                          "description": "When the cached answer was measured. Present on a cache hit whose stored row carries the timestamp. `from_cache: true` alone cannot tell a result measured minutes ago from one about to fall out of the 7-day cache; this can."
                        }
                      },
                      "required": [
                        "domain",
                        "status",
                        "emails",
                        "email_count",
                        "credits_used"
                      ]
                    }
                  },
                  "required": [
                    "success",
                    "data"
                  ]
                }
              }
            }
          },
          "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": "The search could not start or could not be resumed: the cache read was unavailable, a legacy search could not be refreshed, or the credit debit hit transient contention. These fail BEFORE the charge — retry the same GET. Because polling is the same request as starting, a retry either resumes the search already paid for or starts one; it does not buy a second.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "500": {
            "description": "The search could not be started, or the handler caught a failure it could not classify. Four paths in `lambda/domain-search/index.ts` answer this: the claim write that reserves the search, an enqueue that produced no candidates, the credit deduction throwing, and the outer catch.\n\n**On money it is genuinely ambiguous**, which is why it is not the 503. The three \"failed to start\" paths run before or around the debit and the last one can be anything, so the answer does not say. Read `GET /v1/usage` before searching the same domain again. A repeat search of a domain already in the 7-day completed cache is free, so the cheaper first move is simply to call the same GET again and look at `from_cache`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        },
        "x-kawaa-scopes": [
          "verification:write"
        ]
      }
    },
    "/v1/activity/{email}": {
      "get": {
        "tags": [
          "Activity"
        ],
        "operationId": "getEmailActivity",
        "summary": "Engagement history for an address",
        "description": "Opens, clicks, replies, bounces and complaints, from data participating accounts have shared. Charges 0.5 credits.\n\nThis is engagement history, not a verification: it says nothing about whether the mailbox exists. An address with nothing shared comes back with `level: \"unknown\"` and `confidence: 0`, which means \"we have nothing\", not \"this address is inactive\".",
        "parameters": [
          {
            "name": "email",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "email"
            },
            "description": "A syntactically valid email address with no surrounding whitespace. The shared email validator and the explicit whitespace check run before idempotency state or credits move; malformed input is `400` and costs nothing. The successful response echoes this value unchanged."
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "BearerToken": []
          },
          {
            "OAuth2": [
              "verification:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "What is known about the address.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "$ref": "#/components/schemas/EmailActivity"
                    }
                  },
                  "required": [
                    "success",
                    "data"
                  ]
                }
              }
            }
          },
          "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"
          },
          "409": {
            "$ref": "#/components/responses/IdempotencyConflict"
          },
          "503": {
            "description": "The retry protection for this `Idempotency-Key` could not be resolved, or a concurrent attempt was refunded. Nothing was charged. **Retry with the SAME key** — a new one buys the lookup again.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "500": {
            "description": "The lookup was charged for and then failed, or its result could not be stored. The refund is attempted and can itself fail, leaving the charge unsettled — which is what makes this materially different from the 503 beside it, where nothing was charged. Check `GET /v1/usage` before paying for another attempt.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        },
        "x-kawaa-scopes": [
          "verification:write"
        ]
      }
    },
    "/v1/activity/batch": {
      "post": {
        "tags": [
          "Activity"
        ],
        "operationId": "getEmailActivityBatch",
        "summary": "Engagement history for several addresses",
        "description": "The same lookup for a list. A POST because the addresses go in the body — but it is NOT free and NOT side-effect-free: every non-empty batch debits `0.25 credits per address` BEFORE the lookup runs, whatever it finds. Note the rate: the single-address form costs 0.5, so a hundred addresses here is 25 credits, not 50. Send `Idempotency-Key` and reuse it on a retry; without one, a retry after a timeout buys the batch again.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "emails": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "maxLength": 254,
                      "description": "Syntax is checked after trimming surrounding whitespace, but the original string is kept and echoed in the response. No `format: email` is declared because a standard format validator rejects a whitespace-padded address that this route accepts. The 254-character limit is on the raw string, as the handler enforces it."
                    },
                    "minItems": 1,
                    "maxItems": 100,
                    "description": "Up to 100 addresses, each at most 254 raw characters. Syntax is checked after trimming surrounding whitespace, but the submitted spelling is not normalized. The handler refuses the request with 400 before any lookup if the list is longer, or if ANY address is malformed or over-long — one bad entry rejects the whole batch rather than being skipped. Every address in the list is charged for, including one with no recorded activity — the charge is for the lookup, not for a hit."
                  }
                },
                "required": [
                  "emails"
                ]
              }
            }
          }
        },
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "BearerToken": []
          },
          {
            "OAuth2": [
              "verification:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "One row per address, in the order they were sent.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "results": {
                          "type": "array",
                          "description": "One entry per address submitted, in order — so a caller can zip it back to its input.",
                          "items": {
                            "type": "object",
                            "properties": {
                              "email": {
                                "type": "string",
                                "description": "The address exactly as it was sent, including surrounding whitespace. The handler trims only while validating and hashing it, so this response deliberately has no `format: email`: a paid successful response can contain a whitespace-padded value that a standard format validator would reject."
                              },
                              "email_hash": {
                                "type": "string",
                                "description": "SHA-256 of the lower-cased, trimmed address. The key the data is stored under."
                              },
                              "activity": {
                                "anyOf": [
                                  {
                                    "$ref": "#/components/schemas/ActivityData"
                                  },
                                  {
                                    "type": "null"
                                  }
                                ],
                                "description": "`null` means NOTHING IS RECORDED for this address — which says nothing about whether it is deliverable, only that no participating sender has shared engagement for it. It is not a verdict and not a failure."
                              }
                            },
                            "required": [
                              "email",
                              "email_hash",
                              "activity"
                            ]
                          }
                        },
                        "total": {
                          "type": "integer",
                          "description": "`results.length`."
                        },
                        "credits_used": {
                          "type": "number",
                          "description": "0.25 per address submitted, charged whether or not a row was found. **On a replay this is 0.** Repeating the request under the same `Idempotency-Key` returns the earlier result with `credits_used: 0` and `idempotent_replay: true`; the per-address rate describes a NEW lookup only."
                        },
                        "credits_remaining": {
                          "type": "number"
                        },
                        "idempotent_replay": {
                          "type": "boolean",
                          "description": "Present and true when this replayed an earlier call under the same `Idempotency-Key` and charged nothing."
                        }
                      },
                      "required": [
                        "results",
                        "total",
                        "credits_used"
                      ]
                    }
                  },
                  "required": [
                    "success",
                    "data"
                  ]
                }
              }
            }
          },
          "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"
          },
          "409": {
            "$ref": "#/components/responses/IdempotencyConflict"
          },
          "503": {
            "description": "The retry protection for this `Idempotency-Key` could not be resolved, or a concurrent attempt was refunded. Nothing was charged. **Retry with the SAME key** — a new one buys the lookup again.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "500": {
            "description": "The lookup was charged for and then failed, or its result could not be stored. The refund is attempted and can itself fail, leaving the charge unsettled — which is what makes this materially different from the 503 beside it, where nothing was charged. Check `GET /v1/usage` before paying for another attempt.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        },
        "x-kawaa-scopes": [
          "verification:write"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ]
      }
    },
    "/v1/deliverability/check": {
      "post": {
        "tags": [
          "Deliverability"
        ],
        "operationId": "checkDeliverability",
        "summary": "Check a domain's email authentication",
        "description": "Ten credits for a fresh check; a check of the same domain within 24 hours is served from cache and costs nothing.\n\n**Send `Idempotency-Key`.** The DNS work can run for up to 120 seconds while the gateway gives up at about 29, so a client that times out cannot tell whether it paid. With a key, the identical request replays the original result for 30 days and charges nothing; without one, the retry is a new 10-credit check. A key already in flight answers 409, and a receipt that cannot be read answers 503 — in both cases retry the identical request rather than starting a fresh one.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "domain": {
                    "type": "string",
                    "examples": [
                      "example.com"
                    ],
                    "description": "A bare registrable domain name. **Normalized before it is validated**, which is why no `format: hostname` or `pattern` is declared here: `normalizeDomain` trims, lower-cases, punycodes an internationalized domain and then strips ONE leading `www.` — in that order, because IDNA maps the Unicode label separators (`。`, `．`, `｡`) to `.`, so `www。münchen.de` only grows its ASCII dot during the fold. Only then does `isValidDomainFormat` run. So `\" WWW.Example.COM \"` and `münchen.de` are both accepted — the latter is checked as `xn--mnchen-3ya.de`, the same fold `/v1/verify` applies to the domain half of an address (#1332). A scheme, a path or a port is NOT stripped and is a `400 Invalid domain format`; send the host on its own. At least two labels are required, each label 1–63 characters, and the TLD either letters only and at least two (`com`, `de`) or a decodable `xn--` A-label of an internationalized one (`пример.рф` is checked as `xn--e1afmkfd.xn--p1ai`). The A-label must decode AND be the canonical spelling of what it decodes to, not merely carry the prefix: `example.xn--abc` decodes to nothing, and `example.xn---7a` decodes to `¡` whose one canonical form is `xn--7a`. Neither can name a TLD any root zone holds, so both are refused rather than charged for."
                  },
                  "sending_ip": {
                    "type": "string",
                    "description": "The IPv4 address mail is actually sent from, when that is not the domain's own mail host. Leading and trailing whitespace is trimmed before the IPv4 rule is applied, so the raw request string deliberately has no `format` or `pattern` constraint. **IPv4 only** — after trimming, an IPv6 literal or a hostname is a `400` before the check runs, not a fallback. **Only the blocklist lookup falls back to an MX-derived address.** `checkPtr` is handed the normalized `sending_ip` directly and returns `status: \"skipped\"` when there is none, so without one reverse DNS is not measured at all and `server.reverse_dns` is null."
                  },
                  "refresh": {
                    "type": "boolean",
                    "default": false,
                    "description": "Ignore the 24-hour cache and measure again. Costs the full 10 credits, like any other fresh check. Without it a customer who has just fixed their SPF or DMARC record gets the identical old grade back and reasonably concludes the fix did not work — there is no other way to ask for current evidence."
                  }
                },
                "required": [
                  "domain"
                ],
                "additionalProperties": false,
                "description": "Closed: `findUnknownField(body, DELIVERABILITY_CHECK_FIELDS)` allows only `domain`, `sending_ip` and `refresh`, and refuses anything else with 400 before the cache lookup, the DNS work or any credit movement — so a misspelling like `refesh` costs nothing but also achieves nothing."
              }
            }
          }
        },
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "BearerToken": []
          },
          {
            "OAuth2": [
              "deliverability:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The check. `cached` and `idempotent_replay` say whether it was measured now or reused; `credits_used` is what was actually charged.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "$ref": "#/components/schemas/DeliverabilityCheck"
                    }
                  },
                  "required": [
                    "success",
                    "data"
                  ]
                }
              }
            }
          },
          "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"
          },
          "409": {
            "description": "Another request is already using this `Idempotency-Key`. Nothing was charged. Retry the identical request.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "503": {
            "description": "The check could not be completed. Several paths answer this, and **they differ on money**: an unreadable idempotency receipt, an unavailable cache/history read and transient credit contention all fail BEFORE the debit, so nothing was charged. But a check whose DNS lookups all time out fails AFTER it — the handler attempts a refund, and its message says `No credits were charged` only when that refund succeeded. Read the message rather than assuming, and check the balance if it does not say so. Retry with the SAME `Idempotency-Key` either way; a fresh one can buy the check again.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "500": {
            "description": "The check failed AFTER the 10 credits were debited — storing the completed result, for instance. A refund is attempted, and **it can itself fail**: the handler logs that the credits were lost and still answers 500. This is not the same as the 503 beside it, but do not read that one as free either: most of its paths fail BEFORE the debit, and the all-DNS-timeout path fails after it. Read the 503's own description rather than assuming. Check `GET /v1/usage` before buying the check again; retrying with the SAME `Idempotency-Key` is safe and is the cheaper thing to try first.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        },
        "x-kawaa-scopes": [
          "deliverability:write"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ]
      }
    },
    "/v1/deliverability/checks": {
      "get": {
        "tags": [
          "Deliverability"
        ],
        "operationId": "listDeliverabilityChecks",
        "summary": "Past deliverability checks",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "description": "Page size: 1 to 100, defaulting to 20. Unlike the strict `limit` on other list routes, this one CLAMPS rather than refusing — `handleListChecks` runs the raw query value through `parseInt` and then `clampLimit`, so a fraction is truncated, anything above 100 is capped at 100, and a malformed, zero or negative value falls back to 20.\n\nTyped as a plain string with no numeric constraint for that reason: `limit=abc` is a request this route accepts and normalizes, and `type: number` made a generated client refuse it locally. Compare `GET /v1/jobs/{id}`, which checks the digits with `/^[1-9]\\d*$/` first and DOES refuse a malformed value — the two routes differ and the schemas say so.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "BearerToken": []
          },
          {
            "OAuth2": [
              "deliverability:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "Previous checks, newest first.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "checks": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "check_id": {
                                "type": "string"
                              },
                              "domain": {
                                "type": "string"
                              },
                              "sending_ip": {
                                "type": [
                                  "string",
                                  "null"
                                ]
                              },
                              "score": {
                                "type": [
                                  "integer",
                                  "null"
                                ]
                              },
                              "grade": {
                                "type": [
                                  "string",
                                  "null"
                                ]
                              },
                              "checked_at": {
                                "type": "string",
                                "format": "date-time"
                              }
                            }
                          }
                        },
                        "count": {
                          "type": "integer",
                          "description": "Rows in `checks` — at most the applied page size."
                        },
                        "total": {
                          "type": "integer",
                          "description": "Rows SCANNED, not the account's total history. Equal to the history only when `truncated` is false."
                        },
                        "truncated": {
                          "type": "boolean",
                          "description": "True when the scan stopped at its page bound. `checks` is then the newest of what was read, which is not the newest of what exists — there is no way to page past it on this route."
                        }
                      },
                      "required": [
                        "checks",
                        "count",
                        "total",
                        "truncated"
                      ]
                    }
                  },
                  "required": [
                    "success",
                    "data"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "x-kawaa-scopes": [
          "deliverability:read"
        ],
        "description": "Past checks for this account, newest first — **within what was scanned**. The handler reads at most 20 DynamoDB pages in the partition's own (UUID) order and sorts only the rows it read, so on an account with a long history these are not necessarily the newest checks overall. `truncated: true` says that happened."
      }
    },
    "/v1/deliverability/checks/{id}": {
      "get": {
        "tags": [
          "Deliverability"
        ],
        "operationId": "getDeliverabilityCheck",
        "summary": "One past deliverability check",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "BearerToken": []
          },
          {
            "OAuth2": [
              "deliverability:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The stored check.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "$ref": "#/components/schemas/DeliverabilityCheck"
                    }
                  },
                  "required": [
                    "success",
                    "data"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "x-kawaa-scopes": [
          "deliverability:read"
        ],
        "description": "A check that was already run and paid for. Free — this is a read of stored evidence, not a new check."
      }
    },
    "/v1/account": {
      "get": {
        "tags": [
          "Account"
        ],
        "operationId": "getAccount",
        "summary": "Account, credits and recent activity",
        "description": "Profile, credit balance, usage this period, API keys and recent jobs.\n\nTwo fields say when part of the answer is missing, and they mean different things. `partial` names a section that could not be loaded — its value is a placeholder, not a fact. `withheld` names a section this API key's scopes do not cover; those sections come back empty, and an empty `api_keys` beside `withheld.api_keys` never means the account has no keys.\n\nTwo shapes, decided by the credential. A key on an ordinary account gets `Account`; a white-label sub-account's key gets `SubAccount`, which reports `credits.allocated` and `credits.credits_used` instead of an allowance and carries `account.parent_user_id`. Branch on `account.plan === \"sub_account\"`.",
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "BearerToken": []
          },
          {
            "OAuth2": [
              "account:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The account.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "oneOf": [
                        {
                          "$ref": "#/components/schemas/Account"
                        },
                        {
                          "$ref": "#/components/schemas/SubAccount"
                        }
                      ]
                    }
                  },
                  "required": [
                    "success",
                    "data"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "x-kawaa-scopes": [
          "account:read"
        ]
      }
    },
    "/v1/usage": {
      "get": {
        "tags": [
          "Account"
        ],
        "operationId": "getUsage",
        "summary": "Usage and limits",
        "description": "The older usage shape, kept for existing consumers. `GET /v1/usage/summary` is the one to build against.\n\nTwo figures here are deliberately not what they look like: `verifications.daily.used` is `null` when today's count could not be read (never 0, which would read as a measured idle day), and `verifications.daily.percent` is ALWAYS `null` — there is no enforced daily quota to be a percentage of, and `used` counts emails while `limit` counts credits, which are not the same unit.",
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "BearerToken": []
          },
          {
            "OAuth2": [
              "analytics:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "Usage this billing period.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "plan": {
                          "type": "object",
                          "properties": {
                            "id": {
                              "type": "string"
                            },
                            "name": {
                              "type": "string"
                            }
                          },
                          "description": "`{id: \"sub_account\", name: \"Sub-account\"}` for a white-label sub-account, which has no plan of its own."
                        },
                        "credits": {
                          "type": "object",
                          "properties": {
                            "remaining": {
                              "type": "number"
                            },
                            "used": {
                              "type": "number"
                            },
                            "total": {
                              "type": "number"
                            },
                            "allowance_basis": {
                              "type": "string",
                              "enum": [
                                "billing_period",
                                "one_time_grant",
                                "provider_allocation"
                              ]
                            }
                          }
                        },
                        "verifications": {
                          "type": "object",
                          "properties": {
                            "monthly": {
                              "type": "object",
                              "properties": {
                                "used": {
                                  "type": "number"
                                },
                                "limit": {
                                  "type": "number"
                                },
                                "remaining": {
                                  "type": "number",
                                  "description": "The spendable WALLET, not `limit - used`: the balance is the only thing enforcement checks, and purchased packs sit outside the plan grant."
                                },
                                "percent": {
                                  "type": [
                                    "number",
                                    "null"
                                  ]
                                },
                                "resets_at": {
                                  "type": [
                                    "string",
                                    "null"
                                  ],
                                  "format": "date-time"
                                }
                              }
                            },
                            "daily": {
                              "type": "object",
                              "properties": {
                                "used": {
                                  "type": [
                                    "number",
                                    "null"
                                  ],
                                  "description": "`null` when today's count could not be read. Not zero — a fabricated idle day is worse than an honest unknown."
                                },
                                "limit": {
                                  "type": "number",
                                  "description": "The plan's published daily credit number. NOT enforced anywhere; do not size work from it."
                                },
                                "remaining": {
                                  "type": "number",
                                  "description": "The wallet again, for the same reason as the monthly figure."
                                },
                                "percent": {
                                  "type": "null",
                                  "description": "Always null. There is no enforced daily quota, and `used` (emails) and `limit` (credits) are different units."
                                },
                                "resets_at": {
                                  "type": [
                                    "string",
                                    "null"
                                  ],
                                  "format": "date-time"
                                }
                              }
                            }
                          }
                        },
                        "api": {
                          "type": "object",
                          "properties": {
                            "rate_limit": {
                              "type": "number"
                            },
                            "rate_limit_window": {
                              "type": "string"
                            },
                            "rate_limit_enforced": {
                              "type": "boolean",
                              "description": "False means the number beside it is the plan entitlement rather than something being applied."
                            }
                          }
                        },
                        "billing_period": {
                          "type": "object",
                          "properties": {
                            "start": {
                              "type": [
                                "string",
                                "null"
                              ],
                              "format": "date-time"
                            },
                            "end": {
                              "type": [
                                "string",
                                "null"
                              ],
                              "format": "date-time"
                            }
                          }
                        }
                      },
                      "required": [
                        "plan",
                        "credits",
                        "verifications"
                      ]
                    }
                  },
                  "required": [
                    "success",
                    "data"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "x-kawaa-scopes": [
          "analytics:read"
        ]
      }
    },
    "/v1/usage/summary": {
      "get": {
        "tags": [
          "Account"
        ],
        "operationId": "getUsageSummary",
        "summary": "Usage summary",
        "description": "Credits, API calls, verifications and bulk-job concurrency for the current billing period.",
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "BearerToken": []
          },
          {
            "OAuth2": [
              "analytics:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The summary.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "description": "The canonical usage shape. @see shared/contracts/usage.ts, which the dashboard and the Lambda both compile against.",
                      "properties": {
                        "credits": {
                          "type": "object",
                          "properties": {
                            "balance": {
                              "type": "number",
                              "description": "Spendable now. Fractional: a cached verification costs 0.5."
                            },
                            "used_this_month": {
                              "type": "number",
                              "description": "What it measures depends on `allowance_basis` — a period on a paid plan, cumulative since the grant on Free and on a sub-account."
                            },
                            "monthly_allowance": {
                              "type": "number"
                            },
                            "allowance_basis": {
                              "type": "string",
                              "enum": [
                                "billing_period",
                                "one_time_grant",
                                "provider_allocation"
                              ],
                              "description": "`billing_period` renews; `one_time_grant` (Free) and `provider_allocation` (a white-label sub-account) never reset, so the figure beside it is cumulative."
                            },
                            "rollover_credits": {
                              "type": "number"
                            },
                            "bonus_credits": {
                              "type": "number"
                            },
                            "expires_at": {
                              "type": [
                                "string",
                                "null"
                              ],
                              "format": "date-time"
                            }
                          },
                          "required": [
                            "balance",
                            "used_this_month",
                            "monthly_allowance"
                          ]
                        },
                        "api_calls": {
                          "type": "object",
                          "description": "Verification REQUESTS, not addresses. One 5,000-address batch is 1 request and 5,000 verifications.",
                          "properties": {
                            "today": {
                              "type": "integer"
                            },
                            "this_week": {
                              "type": "integer"
                            },
                            "this_month": {
                              "type": "integer"
                            },
                            "daily_credits": {
                              "type": "number"
                            },
                            "monthly_credits": {
                              "type": "number"
                            },
                            "daily_limit": {
                              "type": "number",
                              "deprecated": true,
                              "description": "Misnamed: carries the CREDIT allowance, not a call ceiling. Nothing meters API calls. Read `daily_credits`."
                            },
                            "monthly_limit": {
                              "type": "number",
                              "deprecated": true,
                              "description": "Misnamed in the same way. Read `monthly_credits`."
                            }
                          }
                        },
                        "verifications": {
                          "type": "object",
                          "properties": {
                            "today": {
                              "type": "integer"
                            },
                            "this_week": {
                              "type": "integer"
                            },
                            "this_month": {
                              "type": "integer"
                            },
                            "total": {
                              "type": [
                                "integer",
                                "null"
                              ],
                              "description": "Always `null`: a lifetime verification count is not tracked. The only all-time figure is credit spend, and publishing that as a verification count produced fractional 'verifications'."
                            }
                          }
                        },
                        "bulk_jobs": {
                          "type": "object",
                          "properties": {
                            "active": {
                              "type": "integer"
                            },
                            "completed_this_month": {
                              "type": "integer"
                            },
                            "max_concurrent": {
                              "type": "integer"
                            }
                          }
                        },
                        "billing_period": {
                          "type": "object",
                          "properties": {
                            "start": {
                              "type": [
                                "string",
                                "null"
                              ],
                              "format": "date-time"
                            },
                            "end": {
                              "type": [
                                "string",
                                "null"
                              ],
                              "format": "date-time"
                            },
                            "days_remaining": {
                              "type": [
                                "integer",
                                "null"
                              ]
                            }
                          },
                          "description": "All three are `null` on a plan with no period — and also when a paid plan's period could not be resolved, which is unknown rather than absent."
                        },
                        "partial": {
                          "type": "object",
                          "description": "Names the sections whose counts could NOT BE READ. Their numbers are zero-filled placeholders, not measurements — render them as unavailable, not as zero.",
                          "properties": {
                            "api_calls": {
                              "const": true
                            },
                            "verifications": {
                              "const": true
                            },
                            "bulk_jobs": {
                              "const": true
                            }
                          }
                        }
                      },
                      "required": [
                        "credits",
                        "api_calls",
                        "verifications",
                        "bulk_jobs",
                        "billing_period"
                      ]
                    }
                  },
                  "required": [
                    "success",
                    "data"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "x-kawaa-scopes": [
          "analytics:read"
        ]
      }
    },
    "/v1/api-keys": {
      "get": {
        "tags": [
          "API keys"
        ],
        "operationId": "listApiKeys",
        "summary": "List API keys",
        "description": "Every active key on the account, with the scope vocabulary beside them so a caller that can list keys can also see what a narrower one could be limited to. Never returns a secret: `key_preview` is the stored masked form, and a key created before previews were stored shows `ev_••••`.",
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "BearerToken": []
          },
          {
            "OAuth2": [
              "security:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The keys, and the scope vocabulary.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "api_keys": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/ApiKeySummary"
                          }
                        },
                        "available_scopes": {
                          "type": "array",
                          "description": "The whole scope vocabulary, in the order a UI should present it.",
                          "items": {
                            "type": "object",
                            "properties": {
                              "scope": {
                                "type": "string",
                                "enum": [
                                  "verification:read",
                                  "verification:write",
                                  "lists:read",
                                  "lists:write",
                                  "deliverability:read",
                                  "deliverability:write",
                                  "compose:read",
                                  "compose:write",
                                  "exports:read",
                                  "exports:write",
                                  "webhooks:read",
                                  "webhooks:write",
                                  "integrations:read",
                                  "integrations:write",
                                  "analytics:read",
                                  "analytics:write",
                                  "account:read",
                                  "account:write",
                                  "account:delete",
                                  "billing:read",
                                  "billing:write",
                                  "team:read",
                                  "team:write",
                                  "security:read",
                                  "security:write",
                                  "white_label:read",
                                  "white_label:write"
                                ]
                              },
                              "description": {
                                "type": "string"
                              },
                              "sensitive": {
                                "type": "boolean",
                                "description": "Marks a scope that spends money, destroys data or changes who has access. Present these differently from a read scope."
                              }
                            },
                            "required": [
                              "scope",
                              "description",
                              "sensitive"
                            ]
                          }
                        },
                        "recommended_agent_scopes": {
                          "type": "array",
                          "items": {
                            "type": "string",
                            "enum": [
                              "verification:read",
                              "verification:write",
                              "lists:read",
                              "lists:write",
                              "deliverability:read",
                              "deliverability:write",
                              "compose:read",
                              "compose:write",
                              "exports:read",
                              "exports:write",
                              "webhooks:read",
                              "webhooks:write",
                              "integrations:read",
                              "integrations:write",
                              "analytics:read",
                              "analytics:write",
                              "account:read",
                              "account:write",
                              "account:delete",
                              "billing:read",
                              "billing:write",
                              "team:read",
                              "team:write",
                              "security:read",
                              "security:write",
                              "white_label:read",
                              "white_label:write"
                            ]
                          },
                          "description": "What to give an AI agent doing list hygiene: verify, watch the job, read the result, see what it cost. Nothing that spends money outside verification credits, nothing that changes access, nothing destructive. Published here so the safe default is the discoverable one."
                        }
                      },
                      "required": [
                        "api_keys",
                        "available_scopes",
                        "recommended_agent_scopes"
                      ]
                    }
                  },
                  "required": [
                    "success",
                    "data"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "x-kawaa-scopes": [
          "security:read"
        ]
      },
      "post": {
        "tags": [
          "API keys"
        ],
        "operationId": "createApiKey",
        "summary": "Create an API key",
        "description": "The secret is returned once and never again — `data.api_key.key` is the only place it ever appears.\n\nAccepts `name` and, optionally, `scopes`; any other field is a 400 rather than silently ignored. Omitting `scopes` creates an unrestricted key. Sending `scopes` creates a key limited to exactly those scopes: an empty array is refused (a key that can do nothing is never what was meant) and so is `null` (omitted and null are not the same thing, and this route will not guess which was meant). A scope a key does not hold answers `403 INSUFFICIENT_SCOPE` on use, naming what it needed.\n\nCreating a key with a dashboard session needs a sign-in within the last 15 minutes — an older session answers `403 REAUTHENTICATION_REQUIRED`. Creating one with an API key has no such limit, but the key used must itself be unrestricted: no scope grants minting.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "pattern": "\\S",
                    "description": "A label for the key, for your own benefit. **Measured after trimming**: `handleCreate` runs `rawName.trim()` before the 1-100 length check, so a 100-character name padded with spaces is accepted and stored trimmed, while a raw `maxLength` would refuse it — and `\" \"` satisfies a raw `minLength: 1` and is refused anyway. The `\\S` pattern stays because it is the one part that survives trimming: it requires at least one non-whitespace character."
                  },
                  "scopes": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 27,
                    "items": {
                      "type": "string",
                      "enum": [
                        "verification:read",
                        "verification:write",
                        "lists:read",
                        "lists:write",
                        "deliverability:read",
                        "deliverability:write",
                        "compose:read",
                        "compose:write",
                        "exports:read",
                        "exports:write",
                        "webhooks:read",
                        "webhooks:write",
                        "integrations:read",
                        "integrations:write",
                        "analytics:read",
                        "analytics:write",
                        "account:read",
                        "account:write",
                        "account:delete",
                        "billing:read",
                        "billing:write",
                        "team:read",
                        "team:write",
                        "security:read",
                        "security:write",
                        "white_label:read",
                        "white_label:write"
                      ]
                    },
                    "description": "Limit the new key to these scopes. Omit the field for an unrestricted key; `[]` and `null` are both 400s. A `:write` scope implies its `:read` half, so `verification:write` alone can read results. The stored list is normalized to this document's order, so the response may not list them as sent. Duplicates are accepted and collapsed: `parseRequestedScopes` inserts into a Set and returns the de-duplicated list in a fixed order, so `[\"verification:read\", \"verification:read\"]` creates the same key as one entry. No `uniqueItems` here for that reason — it would refuse locally a payload the API takes. The `maxItems` limit applies to the RAW array, before de-duplication."
                  }
                },
                "required": [
                  "name"
                ],
                "additionalProperties": false
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The new key. Save it now.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "api_key": {
                          "type": "object",
                          "description": "The only response that ever contains the secret.",
                          "properties": {
                            "key": {
                              "type": "string",
                              "description": "THE SECRET. Returned here and nowhere else — no later call can recover it. Store it before reading the rest of this object."
                            },
                            "key_id": {
                              "type": "string",
                              "description": "The key's identifier, used to revoke it. Not secret.",
                              "examples": [
                                "key_a1b2c3d4"
                              ]
                            },
                            "name": {
                              "type": "string"
                            },
                            "created_at": {
                              "type": "string",
                              "format": "date-time"
                            },
                            "scopes": {
                              "type": [
                                "array",
                                "null"
                              ],
                              "items": {
                                "type": "string",
                                "enum": [
                                  "verification:read",
                                  "verification:write",
                                  "lists:read",
                                  "lists:write",
                                  "deliverability:read",
                                  "deliverability:write",
                                  "compose:read",
                                  "compose:write",
                                  "exports:read",
                                  "exports:write",
                                  "webhooks:read",
                                  "webhooks:write",
                                  "integrations:read",
                                  "integrations:write",
                                  "analytics:read",
                                  "analytics:write",
                                  "account:read",
                                  "account:write",
                                  "account:delete",
                                  "billing:read",
                                  "billing:write",
                                  "team:read",
                                  "team:write",
                                  "security:read",
                                  "security:write",
                                  "white_label:read",
                                  "white_label:write"
                                ]
                              },
                              "description": "The scopes the key was created with, normalized and ordered. `null` means unrestricted — always present, so a client never has to tell 'unrestricted' from 'the field was omitted'."
                            },
                            "warning": {
                              "type": "string",
                              "description": "The one-shot warning, as sent.",
                              "examples": [
                                "Save this API key now. You will not be able to see it again."
                              ]
                            }
                          },
                          "required": [
                            "key",
                            "key_id",
                            "name",
                            "created_at",
                            "scopes",
                            "warning"
                          ]
                        }
                      },
                      "required": [
                        "api_key"
                      ]
                    }
                  },
                  "required": [
                    "success",
                    "data"
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "description": "The key could not be created and the outcome is **indeterminate**. `handleCreate` rethrows an unclassified `TransactWriteCommand` failure and the outer handler answers this, so the transaction may have committed with the response lost.\n\nThat matters more here than on most routes, because **the secret is returned once**. If the write landed, the key now exists on the account and nobody has its value — it cannot be recovered, only revoked. Do not retry blind: read `GET /v1/api-keys`, revoke anything that appeared without a secret you hold, and only then create another.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        },
        "x-kawaa-unscoped-only": true
      }
    },
    "/v1/api-keys/{keyId}": {
      "delete": {
        "tags": [
          "API keys"
        ],
        "operationId": "revokeApiKey",
        "summary": "Revoke an API key",
        "description": "The key stops working at once, and so do any dashboard sessions created from it. The account's last active key cannot be revoked — create a replacement first.",
        "parameters": [
          {
            "name": "keyId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "examples": [
                "key_1a2b3c4d"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Revoked.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Envelope"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "description": "Two different states, and they must be told apart by `error.code`.\n\n`SESSION_REVOCATION_INCOMPLETE`: **the key is revoked** — it stops working on its next use — but not every dashboard session minted from it could be signed out. Retry to finish signing them out.\n\n`INTERNAL_ERROR`: the failure happened BEFORE the revocation, so **the key may still be active**. Retry, and confirm with `GET /v1/api-keys` that it is gone. Treating this like the first one leaves an exposed credential live while its owner believes it was revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        },
        "x-kawaa-scopes": [
          "security:write"
        ]
      }
    },
    "/v1/webhooks": {
      "get": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "listWebhooks",
        "summary": "List webhook endpoints",
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "BearerToken": []
          },
          {
            "OAuth2": [
              "webhooks:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The account's endpoints.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "webhooks": {
                          "type": "array",
                          "description": "Every endpoint on the account. **Never includes `secret`** — a signing secret appears only in the successful response that creates or explicitly rotates it.",
                          "items": {
                            "type": "object",
                            "properties": {
                              "webhook_id": {
                                "type": "string",
                                "description": "What the get, update, delete and test operations take as `{id}`."
                              },
                              "url": {
                                "type": [
                                  "string",
                                  "null"
                                ],
                                "format": "uri",
                                "description": "The canonical callback URL. `null` identifies a malformed legacy endpoint that cannot deliver; repair it with PUT or delete it. One such row does not hide the account's other endpoints."
                              },
                              "description": {
                                "type": [
                                  "string",
                                  "null"
                                ]
                              },
                              "events": {
                                "type": "array",
                                "items": {
                                  "type": "string"
                                }
                              },
                              "is_active": {
                                "type": "boolean",
                                "description": "False means Kawaa has stopped delivering to it. Set it back with the update operation."
                              },
                              "created_at": {
                                "type": "string",
                                "format": "date-time"
                              },
                              "updated_at": {
                                "type": "string",
                                "format": "date-time",
                                "description": "Falls back to `created_at` on an endpoint that has never been changed."
                              },
                              "last_triggered_at": {
                                "type": [
                                  "string",
                                  "null"
                                ],
                                "format": "date-time",
                                "description": "Null on an endpoint that has never fired — which is not the same as one that is failing."
                              },
                              "failure_count": {
                                "type": "integer",
                                "description": "Consecutive delivery failures. A rising number beside a recent `last_triggered_at` is an endpoint that is being called and refusing."
                              }
                            },
                            "required": [
                              "webhook_id",
                              "url",
                              "events",
                              "is_active",
                              "created_at"
                            ]
                          }
                        },
                        "count": {
                          "type": "integer",
                          "description": "Rows returned. The quota counter behind `PLAN_LIMIT_EXCEEDED` is maintained separately and is the authoritative one — this is a count of what was read."
                        }
                      },
                      "required": [
                        "webhooks",
                        "count"
                      ]
                    }
                  },
                  "required": [
                    "success",
                    "data"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "x-kawaa-scopes": [
          "webhooks:read"
        ]
      },
      "post": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "createWebhook",
        "summary": "Register a webhook endpoint",
        "description": "Kawaa POSTs to `url` when one of `events` happens, signing each delivery. Past the plan's endpoint cap this answers `403 PLAN_LIMIT_EXCEEDED`.\n\n**A scoped key needs more than `webhooks:write` here.** A subscription is a way to receive what its events carry, so each requested event also requires the scope that owns that data: `verification.completed` needs `verification:read`, `verification.failed` needs `verification:read`, `job.completed` needs `verification:read`, `job.failed` needs `verification:read`, `credits.low` needs `account:read`, `credits.exhausted` needs `account:read`, `blacklist.detected` needs `deliverability:read`, `dmarc.failure` needs `deliverability:read`. On an account that can have sub-accounts, `credits.low` and `credits.exhausted` additionally require `white_label:read`, because those deliveries name the sub-account and its label. Missing any of them is a `403` naming each event and the scope it needed, and no webhook is created. An unrestricted key needs none of this. `x-kawaa-event-scopes` carries the same table in machine-readable form.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "url": {
                    "type": "string",
                    "description": "Where Kawaa POSTs the callback. HTTPS only — anything else is 400 WEBHOOK_URL_INVALID. No `format: uri` or anchored scheme `pattern` is declared on this REQUEST field: `validateWebhookUrlStatic` parses with `new URL()`, which tolerates surrounding whitespace and normalizes the protocol, so `\" https://x\"` and `\"HTTPS://x\"` are both accepted while either constraint refuses them. HTTPS is required — checked on the PARSED protocol, not on the string you sent. Before storing it, Kawaa replaces the request string with that parsed URL's canonical string, so every response URL is a well-formed URI.",
                    "maxLength": 2048
                  },
                  "events": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "minItems": 1
                  },
                  "description": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "A note for your own reference. Send `null` to clear the stored one.",
                    "maxLength": 1000
                  }
                },
                "required": [
                  "url",
                  "events"
                ],
                "additionalProperties": false,
                "description": "Closed: `findUnknownField(body, WEBHOOK_CREATE_FIELDS)` allows only `url`, `description` and `events`. `is_active` is NOT one of them — an endpoint is created active, and it is the update operation that can deactivate it."
              }
            }
          }
        },
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "BearerToken": []
          },
          {
            "OAuth2": [
              "webhooks:write"
            ]
          }
        ],
        "responses": {
          "201": {
            "description": "Registered. This endpoint's initial signing secret is returned once; no read operation returns it, but the rotation operation can replace a lost or exposed value.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "webhook": {
                          "type": "object",
                          "properties": {
                            "webhook_id": {
                              "type": "string",
                              "examples": [
                                "wh_3f9a1c2b4d5e6f7a8b9c0d1e2f3a4b5c"
                              ]
                            },
                            "url": {
                              "type": "string",
                              "format": "uri"
                            },
                            "description": {
                              "type": [
                                "string",
                                "null"
                              ]
                            },
                            "events": {
                              "type": "array",
                              "items": {
                                "type": "string"
                              }
                            },
                            "is_active": {
                              "type": "boolean"
                            },
                            "created_at": {
                              "type": "string",
                              "format": "date-time"
                            },
                            "secret": {
                              "type": "string",
                              "description": "THE SIGNING SECRET. Every delivery to this endpoint carries `X-Kawaa-Signature` computed from it; without it there is no way to tell a genuine callback from anything else that can reach the URL. Save it now: no read operation returns it. If it is lost or exposed, `POST /v1/webhooks/{id}/secret` replaces it and returns the replacement once, without deleting the endpoint."
                            }
                          },
                          "required": [
                            "webhook_id",
                            "url",
                            "events",
                            "is_active",
                            "secret",
                            "created_at"
                          ]
                        },
                        "message": {
                          "type": "string"
                        }
                      },
                      "required": [
                        "webhook",
                        "message"
                      ]
                    }
                  },
                  "required": [
                    "success",
                    "data"
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "503": {
            "description": "The webhook URL's hostname could not be resolved right now (`WEBHOOK_DNS_TEMPORARY_FAILURE`). This is a TRANSIENT DNS failure, not a rejection of the URL — a permanent one is `400 WEBHOOK_URL_INVALID`. Nothing was created or changed. Retry.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "500": {
            "description": "The endpoint could not be created and the outcome is **indeterminate**. The outer handler answers this when the quota read or the `TransactWriteCommand` throws, and a transport failure on that transaction can leave the write committed with no response to show for it.\n\nThis POST is **not idempotent** — it takes no `Idempotency-Key`, and a blind retry can register a SECOND endpoint that then receives duplicate callbacks for every event. Read `GET /v1/webhooks` first and retry only if the endpoint is not there.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        },
        "x-kawaa-scopes": [
          "webhooks:write"
        ],
        "x-kawaa-event-scopes": {
          "verification.completed": "verification:read",
          "verification.failed": "verification:read",
          "job.completed": "verification:read",
          "job.failed": "verification:read",
          "credits.low": "account:read",
          "credits.exhausted": "account:read",
          "blacklist.detected": "deliverability:read",
          "dmarc.failure": "deliverability:read"
        },
        "x-kawaa-event-scopes-white-label": {
          "events": [
            "credits.low",
            "credits.exhausted"
          ],
          "additional_scope": "white_label:read",
          "applies_when": "the account can have sub-accounts"
        }
      }
    },
    "/v1/webhooks/events": {
      "get": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "listWebhookEvents",
        "summary": "The events you can subscribe to",
        "description": "Every event name, what triggers it, and an example payload. Read this rather than hard-coding a list.",
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "BearerToken": []
          },
          {
            "OAuth2": [
              "webhooks:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The event catalogue.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "events": {
                          "type": "array",
                          "description": "Every event name a subscription may name. Read this rather than hard-coding: an event Kawaa does not know is refused at create.",
                          "items": {
                            "type": "object",
                            "properties": {
                              "name": {
                                "type": "string",
                                "examples": [
                                  "verification.completed",
                                  "job.failed"
                                ]
                              },
                              "description": {
                                "type": "string"
                              },
                              "category": {
                                "type": "string",
                                "examples": [
                                  "verification",
                                  "account",
                                  "deliverability"
                                ]
                              },
                              "available": {
                                "type": "boolean",
                                "description": "**False means nothing produces this event yet, and a subscription naming it is REFUSED.** `normalizeAndValidateEvents` rejects it with 400 on create and on update, and names the available events in the message. It is listed here rather than hidden so a client can show the whole catalogue and say which entries are not ready, instead of discovering the refusal at submit time. Filter on this before offering an event as a choice."
                              },
                              "payload_example": {
                                "type": "object",
                                "description": "The shape a delivery of this event carries, as `{event, data}`."
                              }
                            },
                            "required": [
                              "name",
                              "description",
                              "category",
                              "available"
                            ]
                          }
                        }
                      },
                      "required": [
                        "events"
                      ]
                    }
                  },
                  "required": [
                    "success",
                    "data"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "x-kawaa-scopes": [
          "webhooks:read"
        ]
      }
    },
    "/v1/webhooks/{id}": {
      "get": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "getWebhook",
        "summary": "One webhook endpoint",
        "parameters": [
          {
            "$ref": "#/components/parameters/WebhookId"
          }
        ],
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "BearerToken": []
          },
          {
            "OAuth2": [
              "webhooks:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The endpoint.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "description": "The endpoint, directly — there is no `webhook` wrapper. Same shape as a row of the listing, and it never carries `secret` either: a signing secret appears only in the successful response that creates or explicitly rotates it.",
                      "properties": {
                        "webhook_id": {
                          "type": "string",
                          "description": "What the get, update, delete and test operations take as `{id}`."
                        },
                        "url": {
                          "type": [
                            "string",
                            "null"
                          ],
                          "format": "uri",
                          "description": "The canonical callback URL. `null` identifies a malformed legacy endpoint that cannot deliver; repair it with PUT or delete it."
                        },
                        "description": {
                          "type": [
                            "string",
                            "null"
                          ]
                        },
                        "events": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          }
                        },
                        "is_active": {
                          "type": "boolean",
                          "description": "False means Kawaa has stopped delivering to it. Set it back with the update operation."
                        },
                        "created_at": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "updated_at": {
                          "type": "string",
                          "format": "date-time",
                          "description": "Falls back to `created_at` on an endpoint that has never been changed."
                        },
                        "last_triggered_at": {
                          "type": [
                            "string",
                            "null"
                          ],
                          "format": "date-time",
                          "description": "Null on an endpoint that has never fired — which is not the same as one that is failing."
                        },
                        "failure_count": {
                          "type": "integer",
                          "description": "Consecutive delivery failures. A rising number beside a recent `last_triggered_at` is an endpoint that is being called and refusing."
                        }
                      },
                      "required": [
                        "webhook_id",
                        "url",
                        "events",
                        "is_active",
                        "created_at"
                      ]
                    }
                  },
                  "required": [
                    "success",
                    "data"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "x-kawaa-scopes": [
          "webhooks:read"
        ]
      },
      "put": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "updateWebhook",
        "summary": "Change a webhook endpoint",
        "parameters": [
          {
            "$ref": "#/components/parameters/WebhookId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "url": {
                    "type": "string",
                    "description": "Where Kawaa POSTs the callback. HTTPS only — anything else is 400 WEBHOOK_URL_INVALID. No `format: uri` or anchored scheme `pattern` is declared on this REQUEST field: `validateWebhookUrlStatic` parses with `new URL()`, which tolerates surrounding whitespace and normalizes the protocol, so `\" https://x\"` and `\"HTTPS://x\"` are both accepted while either constraint refuses them. HTTPS is required — checked on the PARSED protocol, not on the string you sent. Before storing it, Kawaa replaces the request string with that parsed URL's canonical string, so every response URL is a well-formed URI.",
                    "maxLength": 2048
                  },
                  "events": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "minItems": 1,
                    "description": "Omit the field to leave the subscription alone. Sending it EMPTY is not \"unsubscribe from everything\" — the handler answers `400 At least one event is required`. Delete the endpoint, or set `is_active: false`, to stop delivery."
                  },
                  "description": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "A note for your own reference. Send `null` to clear the stored one.",
                    "maxLength": 1000
                  },
                  "is_active": {
                    "type": "boolean",
                    "description": "Pause or reactivate the webhook. The handler allow-lists url, description, events and is_active; anything else is a 400."
                  },
                  "webhook_id": {
                    "description": "What the get, update, delete and test operations take as `{id}`. Accepted here and IGNORED, whatever its value. The update stores only `url`, `description`, `events` and `is_active`; this field is in the allow-list so a `GET /v1/webhooks/{id}` payload can be edited and sent straight back without stripping the fields the API itself handed out. It is not marked `readOnly`, because OpenAPI clients omit read-only properties from request models and would break that supported round trip."
                  },
                  "created_at": {
                    "description": "Accepted here and IGNORED, whatever its value. The update stores only `url`, `description`, `events` and `is_active`; this field is in the allow-list so a `GET /v1/webhooks/{id}` payload can be edited and sent straight back without stripping the fields the API itself handed out. The response schema types the timestamp; this no-op request property deliberately adds no constraint the handler does not enforce."
                  },
                  "updated_at": {
                    "description": "Falls back to `created_at` on an endpoint that has never been changed. Accepted here and IGNORED, whatever its value. The update stores only `url`, `description`, `events` and `is_active`; this field is in the allow-list so a `GET /v1/webhooks/{id}` payload can be edited and sent straight back without stripping the fields the API itself handed out. The response schema types the timestamp; this no-op request property deliberately adds no constraint the handler does not enforce."
                  },
                  "last_triggered_at": {
                    "description": "Null on an endpoint that has never fired — which is not the same as one that is failing. Accepted here and IGNORED, whatever its value. The update stores only `url`, `description`, `events` and `is_active`; this field is in the allow-list so a `GET /v1/webhooks/{id}` payload can be edited and sent straight back without stripping the fields the API itself handed out. The response schema types the timestamp; this no-op request property deliberately adds no constraint the handler does not enforce."
                  },
                  "failure_count": {
                    "description": "Consecutive delivery failures. A rising number beside a recent `last_triggered_at` is an endpoint that is being called and refusing. Accepted here and IGNORED, whatever its value. The update stores only `url`, `description`, `events` and `is_active`; this field is in the allow-list so a `GET /v1/webhooks/{id}` payload can be edited and sent straight back without stripping the fields the API itself handed out. The response schema types the count; this no-op request property deliberately adds no constraint the handler does not enforce."
                  }
                },
                "additionalProperties": false,
                "description": "Send only what you are changing. `findUnknownField(body, WEBHOOK_UPDATE_FIELDS, WEBHOOK_READ_ONLY_FIELDS)` refuses the first key that is neither writable nor a known response field with 400 — so a misspelling is caught, while a round trip of a fetched endpoint object is deliberately accepted. The five known response fields below are the no-op half of that: present is fine, changing them is not possible. They are intentionally not marked `readOnly`, because generated request models would otherwise omit them and break the supported round trip."
              }
            }
          }
        },
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "BearerToken": []
          },
          {
            "OAuth2": [
              "webhooks:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "Updated.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "webhook": {
                          "type": "object",
                          "properties": {
                            "webhook_id": {
                              "type": "string",
                              "examples": [
                                "wh_3f9a1c2b4d5e6f7a8b9c0d1e2f3a4b5c"
                              ]
                            },
                            "url": {
                              "type": [
                                "string",
                                "null"
                              ],
                              "format": "uri",
                              "description": "The canonical callback URL. This is `null` only when the update omitted `url` and the stored legacy destination was malformed; send a valid URL in another PUT to repair it."
                            },
                            "description": {
                              "type": [
                                "string",
                                "null"
                              ]
                            },
                            "events": {
                              "type": "array",
                              "items": {
                                "type": "string"
                              }
                            },
                            "is_active": {
                              "type": "boolean"
                            },
                            "updated_at": {
                              "type": "string",
                              "format": "date-time",
                              "description": "When this change landed. The update response carries only this timestamp — `created_at` is not returned here; read it from the listing or the single-webhook route."
                            }
                          },
                          "description": "The endpoint as it now stands, including a description cleared in this same request: the response reports the stored value for every field, so there is no need to re-read after an update. (Until #1338 it echoed the PREVIOUS description when you cleared one with `{\"description\": null}`, because the builder could not tell \"omitted\" from \"explicitly cleared\".) No `secret`: a signing secret appears only in the successful response that creates or explicitly rotates it.",
                          "required": [
                            "webhook_id",
                            "url",
                            "description",
                            "events",
                            "is_active",
                            "updated_at"
                          ]
                        },
                        "message": {
                          "type": "string"
                        }
                      },
                      "required": [
                        "webhook",
                        "message"
                      ]
                    }
                  },
                  "required": [
                    "success",
                    "data"
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "503": {
            "description": "The webhook URL's hostname could not be resolved right now (`WEBHOOK_DNS_TEMPORARY_FAILURE`). This is a TRANSIENT DNS failure, not a rejection of the URL — a permanent one is `400 WEBHOOK_URL_INVALID`. Nothing was created or changed. Retry.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        },
        "x-kawaa-scopes": [
          "webhooks:write"
        ],
        "description": "Change an endpoint. Only the fields sent are changed.\n\n**A scoped key needs more than `webhooks:write` here.** A subscription is a way to receive what its events carry, so each requested event also requires the scope that owns that data: `verification.completed` needs `verification:read`, `verification.failed` needs `verification:read`, `job.completed` needs `verification:read`, `job.failed` needs `verification:read`, `credits.low` needs `account:read`, `credits.exhausted` needs `account:read`, `blacklist.detected` needs `deliverability:read`, `dmarc.failure` needs `deliverability:read`. On an account that can have sub-accounts, `credits.low` and `credits.exhausted` additionally require `white_label:read`, because those deliveries name the sub-account and its label. Missing any of them is a `403` naming each event and the scope it needed, and no webhook is created. An unrestricted key needs none of this. `x-kawaa-event-scopes` carries the same table in machine-readable form.",
        "x-kawaa-event-scopes": {
          "verification.completed": "verification:read",
          "verification.failed": "verification:read",
          "job.completed": "verification:read",
          "job.failed": "verification:read",
          "credits.low": "account:read",
          "credits.exhausted": "account:read",
          "blacklist.detected": "deliverability:read",
          "dmarc.failure": "deliverability:read"
        },
        "x-kawaa-event-scopes-white-label": {
          "events": [
            "credits.low",
            "credits.exhausted"
          ],
          "additional_scope": "white_label:read",
          "applies_when": "the account can have sub-accounts"
        }
      },
      "delete": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "deleteWebhook",
        "summary": "Delete a webhook endpoint",
        "parameters": [
          {
            "$ref": "#/components/parameters/WebhookId"
          }
        ],
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "BearerToken": []
          },
          {
            "OAuth2": [
              "webhooks:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Envelope"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "x-kawaa-scopes": [
          "webhooks:write"
        ]
      }
    },
    "/v1/webhooks/{id}/secret": {
      "post": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "regenerateWebhookSecret",
        "summary": "Rotate a webhook signing secret",
        "description": "Replace the endpoint's signing secret without deleting or reconfiguring the endpoint. A successful call returns the replacement exactly once; deliveries that load the endpoint afterward use it, while a delivery already in flight may still carry a signature made with the previous value. Store the returned value before leaving the response; no read operation can recover it.",
        "parameters": [
          {
            "$ref": "#/components/parameters/WebhookId"
          }
        ],
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "BearerToken": []
          },
          {
            "OAuth2": [
              "webhooks:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The secret was replaced. Subsequent deliveries are signed with this value, except that a delivery already in flight may have loaded the previous secret before rotation.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "secret": {
                          "type": "string",
                          "description": "The replacement signing secret, returned by this successful rotation only. It is now the value used for `X-Kawaa-Signature`."
                        },
                        "message": {
                          "type": "string"
                        }
                      },
                      "required": [
                        "secret",
                        "message"
                      ]
                    }
                  },
                  "required": [
                    "success",
                    "data"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "description": "The rotation outcome is **indeterminate**: the write may have committed even though its response was lost. Retrying is safe for endpoint configuration but rotates again. Retry this same operation until one call succeeds, then keep only the secret from that successful response; it supersedes every earlier value.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        },
        "x-kawaa-scopes": [
          "webhooks:write"
        ]
      }
    },
    "/v1/webhooks/{id}/test": {
      "post": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "testWebhook",
        "summary": "Send a test delivery",
        "description": "Delivers a signed sample payload to the endpoint so you can confirm signature verification before real events arrive.",
        "parameters": [
          {
            "$ref": "#/components/parameters/WebhookId"
          }
        ],
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "BearerToken": []
          },
          {
            "OAuth2": [
              "webhooks:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The delivery was ATTEMPTED. **200 does not mean it worked** — branch on `data.success`.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "success": {
                          "type": "boolean",
                          "description": "Whether the endpoint accepted the delivery (a 2xx). False for a non-2xx, a timeout, a connection failure, and a URL refused as unsafe — all of which still answer HTTP 200 at this route, because the REQUEST succeeded even though the DELIVERY did not."
                        },
                        "status_code": {
                          "type": "integer",
                          "description": "What the endpoint answered. `0` when nothing was reached."
                        },
                        "response_time_ms": {
                          "type": "integer"
                        },
                        "response_body": {
                          "type": [
                            "string",
                            "null"
                          ],
                          "description": "The first part of the endpoint's body, for diagnosing a rejection. Null when there was none."
                        },
                        "message": {
                          "type": "string",
                          "description": "What happened, in prose — the only place a DNS refusal or an SSRF block is explained."
                        },
                        "event": {
                          "type": "string",
                          "description": "The canonical event whose sample payload was delivered, and the value of the `X-Kawaa-Event` header on that delivery. Equals what you sent, with a legacy alias resolved to its canonical name, or `verification.completed` when the request named no event. Read it to confirm which integration path the test actually exercised (#1331)."
                        }
                      },
                      "required": [
                        "success",
                        "status_code",
                        "response_time_ms",
                        "response_body",
                        "message",
                        "event"
                      ]
                    }
                  },
                  "required": [
                    "success",
                    "data"
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "x-kawaa-scopes": [
          "webhooks:write"
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "event": {
                    "type": "string",
                    "description": "Which event's sample payload to send. Omitted, the FIRST event Kawaa defines (`verification.completed`) is used — not necessarily one this webhook is subscribed to, so a test with no body can exercise an event the endpoint never receives in practice. Explicit `null` and other non-string values are refused with 400; only omission selects the default. **An unrecognised name is refused with `400 Invalid events: <name>`** — the same message create and update give — so a mistyped `dmarc.failur` fails loudly instead of delivering a `verification.completed` sample and reporting success (#1331). Legacy aliases (`batch.completed`, `credits.depleted`) resolve to their canonical names. The response's `event` field names whichever event was actually sent. Valid names come from `GET /v1/webhooks/events`."
                  }
                },
                "description": "Optional. `event` picks which event shape the test delivery carries; omit the body entirely and the endpoint picks one.\n\nDeliberately NOT closed, because this route is not: it parses the body and reads `body.event`, and anything else is ignored rather than refused. That is unlike create and update, which run `findUnknownField` and answer 400 for the first key they do not recognise — so a misspelled `evnet` here still selects the default event rather than failing. The VALUE of `event` is now validated (an unknown name is a 400), so the remaining gap is a misspelled KEY. Check the response's `event` field to confirm which sample was sent. Send only `event`."
              }
            }
          }
        }
      }
    },
    "/v1/billing/plans": {
      "get": {
        "tags": [
          "Catalogue"
        ],
        "operationId": "listPlans",
        "summary": "Plans and prices",
        "description": "The plan catalogue. Needs a valid API key like every other route here — `is_current` marks the plan that key's account is on.\n\nEvery `price` is quoted in `currency`, which is always USD: this endpoint does not take a currency and does not convert. `billable_currencies` is the separate question of which currencies a subscription can be created in, and `credit_package_currencies` the same question for one-off credit packs — those two lists differ, so offering a currency from the wrong one produces a checkout the customer cannot complete.\n\n**A white-label sub-account has no plan of its own.** The route is open to sub-accounts — it is the public price list — but such an account spends a credit pool its parent allocated and holds no subscription. It gets `current_plan: \"sub_account\"` (agreeing with `GET /v1/account`), `is_current: false` on every plan, and a `plan_note` saying where the subscription lives. Do not present a plan as that account's, and expect `403 SUB_ACCOUNT_MANAGED` from every subscription write (#1333).",
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "BearerToken": []
          },
          {
            "OAuth2": [
              "billing:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The plans.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "plans": {
                          "type": "array",
                          "description": "Every plan, in catalogue order, free first. A sub-account is not in here: it has no plan of its own.",
                          "items": {
                            "type": "object",
                            "properties": {
                              "id": {
                                "type": "string",
                                "enum": [
                                  "free",
                                  "starter",
                                  "professional",
                                  "business",
                                  "enterprise"
                                ],
                                "description": "The value `POST /v1/billing/checkout` takes as `plan`."
                              },
                              "name": {
                                "type": "string",
                                "description": "The display name.",
                                "examples": [
                                  "Professional"
                                ]
                              },
                              "price": {
                                "type": "number",
                                "description": "Per `interval`, in `currency`, in whole currency units (not cents). `0` for free."
                              },
                              "currency": {
                                "type": "string",
                                "enum": [
                                  "USD",
                                  "EUR",
                                  "GBP",
                                  "BRL",
                                  "INR"
                                ],
                                "description": "Always `USD` today — the catalogue is quoted in one currency. @see billable_currencies for what a subscription can be created in."
                              },
                              "interval": {
                                "const": "month",
                                "description": "Every plan here is monthly."
                              },
                              "credits": {
                                "type": "number",
                                "description": "The monthly credit allowance. Not a verification count — a cached verification costs 0.5 and a domain search 10."
                              },
                              "features": {
                                "type": "array",
                                "items": {
                                  "type": "string"
                                },
                                "description": "Marketing lines for the plan, in display order. Not a machine-readable entitlement list; read `GET /v1/usage/limits` for the enforced numbers."
                              },
                              "is_current": {
                                "type": "boolean",
                                "description": "True for the plan the calling key's account is on. False on every plan for a white-label sub-account, which is subscribed to none of them (#1333)."
                              }
                            },
                            "required": [
                              "id",
                              "name",
                              "price",
                              "currency",
                              "interval",
                              "credits",
                              "features",
                              "is_current"
                            ]
                          }
                        },
                        "current_plan": {
                          "type": "string",
                          "description": "The account's plan id, repeated here so a client does not have to scan `plans` for `is_current`. `free` when the account has no subscription. `sub_account` for a white-label sub-account, which has no plan of its own — see `plan_note`, and note that no entry in `plans` is marked `is_current` in that case (#1333)."
                        },
                        "plan_note": {
                          "type": "string",
                          "description": "Present only when `current_plan` is not a plan the account is subscribed to — today, only for a white-label sub-account, whose subscription is held by its parent and whose credits are the pool that parent allocated. Same field, and the same purpose, as `plan_note` on `GET /v1/usage/limits`. Absent for every ordinary account."
                        },
                        "currency": {
                          "type": "string",
                          "enum": [
                            "USD",
                            "EUR",
                            "GBP",
                            "BRL",
                            "INR"
                          ],
                          "description": "The currency every `price` above is quoted in."
                        },
                        "billable_currencies": {
                          "type": "array",
                          "items": {
                            "type": "string",
                            "enum": [
                              "USD",
                              "EUR",
                              "GBP",
                              "BRL",
                              "INR"
                            ]
                          },
                          "description": "The currencies a SUBSCRIPTION can actually be created in — derived from the Stripe prices that exist, so it can be shorter than the supported list and can change without a deploy. A currency outside it is refused at checkout."
                        },
                        "credit_package_currencies": {
                          "type": "array",
                          "items": {
                            "type": "string",
                            "enum": [
                              "USD",
                              "EUR",
                              "GBP",
                              "BRL",
                              "INR"
                            ]
                          },
                          "description": "The currencies a one-off CREDIT PACK can be bought in. Packs are priced inline at checkout rather than from a stored Stripe price, so this list is not bound by `billable_currencies` and is usually longer."
                        }
                      },
                      "required": [
                        "plans",
                        "current_plan",
                        "currency",
                        "billable_currencies",
                        "credit_package_currencies"
                      ]
                    }
                  },
                  "required": [
                    "success",
                    "data"
                  ]
                }
              }
            },
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "x-kawaa-scopes": [
          "billing:read"
        ]
      }
    },
    "/v1/billing/credit-packages": {
      "get": {
        "tags": [
          "Catalogue"
        ],
        "operationId": "listCreditPackages",
        "summary": "Credit packs and prices",
        "description": "One-off credit packs. Needs a valid API key.\n\nPrices are quoted in `currency`, which defaults to USD and is set by the query parameter — each pack has a price in every supported currency, so this returns one currency's prices, not all of them. A currency Kawaa cannot price is a `400`, not a silent fallback to USD: quoting dollar amounts under another currency's label is how a buyer ends up comparing the wrong numbers.",
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "BearerToken": []
          },
          {
            "OAuth2": [
              "billing:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The packs, priced in the requested currency.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "packages": {
                          "type": "array",
                          "description": "Every pack, smallest first. The list is fixed; only the prices move with `currency`.",
                          "items": {
                            "type": "object",
                            "properties": {
                              "package_id": {
                                "type": "string",
                                "description": "The value `POST /v1/billing/purchase-credits` takes as `package_id`.",
                                "examples": [
                                  "credits_10000"
                                ]
                              },
                              "name": {
                                "type": "string",
                                "description": "A display name built from the credit count.",
                                "examples": [
                                  "10,000 Credits"
                                ]
                              },
                              "credits": {
                                "type": "integer",
                                "description": "Credits added on purchase. They do not expire with a billing period."
                              },
                              "price": {
                                "type": "number",
                                "description": "One-off charge in `currency`, in whole currency units (not cents)."
                              },
                              "currency": {
                                "type": "string",
                                "enum": [
                                  "usd",
                                  "eur",
                                  "gbp",
                                  "brl",
                                  "inr"
                                ],
                                "description": "LOWER-CASE here, unlike the plan catalogue, which reports it upper-case. Same value, two spellings — compare case-insensitively."
                              },
                              "savings_percent": {
                                "type": [
                                  "integer",
                                  "null"
                                ],
                                "description": "Discount against the smallest pack's per-credit rate, as a whole number. `null` for the smallest pack, which is the baseline."
                              },
                              "popular": {
                                "type": "boolean",
                                "description": "A display hint for the pack Kawaa highlights. Not an entitlement and not a price."
                              }
                            },
                            "required": [
                              "package_id",
                              "name",
                              "credits",
                              "price",
                              "currency",
                              "savings_percent",
                              "popular"
                            ]
                          }
                        }
                      },
                      "required": [
                        "packages"
                      ]
                    }
                  },
                  "required": [
                    "success",
                    "data"
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "x-kawaa-scopes": [
          "billing:read"
        ],
        "parameters": [
          {
            "name": "currency",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "pattern": "^(?:[Bb][Rr][Ll]|[Ee][Uu][Rr]|[Gg][Bb][Pp]|[Ii][Nn][Rr]|[Uu][Ss][Dd])$",
              "default": "USD"
            },
            "description": "Quote the packs in this currency: BRL, EUR, GBP, INR, USD. **Genuinely case-insensitive** — `isSupportedCurrency` upper-cases the value before checking it, so `usd`, `USD` and `Usd` are the same request. Expressed as a case-insensitive `pattern` rather than an `enum` on purpose: an enum can only list casings, and listing `USD` and `usd` still made a generated validator refuse `Usd`, which the API accepts. The response echoes the code lower-case. Anything else is 400."
          }
        ]
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ApiKeyHeader": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Api-Key",
        "description": "A Kawaa API key, created in Settings → API keys. Format: `ev_` followed by 32 alphanumeric characters."
      },
      "BearerToken": {
        "type": "http",
        "scheme": "bearer",
        "description": "The same API key, sent as `Authorization: Bearer <key>`. Accepted everywhere `X-Api-Key` is. This scheme is the `ev_` API key only. An OAuth access token (`evo_` followed by 40 alphanumeric characters) travels in the same header, but it is the `OAuth2` scheme: it is always scoped, and it is accepted only by the operations that list `OAuth2`, with the scopes they name. An operation that lists `BearerToken` without `OAuth2` refuses it."
      },
      "OAuth2": {
        "type": "oauth2",
        "description": "OAuth 2.1 sign-in, for an app or agent that acts for a person who approves it on a Kawaa consent screen, instead of being handed an API key. The access token is sent as `Authorization: Bearer <token>`, the same header as `BearerToken`, and is checked exactly like a scoped API key: scopes, rate limits and credits apply unchanged.\n\n- **Discovery.** Authorization server metadata (RFC 8414): `https://api.kawaa.com/.well-known/oauth-authorization-server`. Protected resource metadata (RFC 9728): `https://api.kawaa.com/.well-known/oauth-protected-resource` for this API and `https://api.kawaa.com/.well-known/oauth-protected-resource/mcp` for the MCP server.\n- **Client.** Use the https URL of a Client ID Metadata Document as `client_id` (public clients only), or register at `https://api.kawaa.com/oauth/register` (RFC 7591; no credential needed). Registration follows the RFC defaults when a field is omitted: `token_endpoint_auth_method` `client_secret_basic` (a secret is issued) and `grant_types` `authorization_code` alone. Send `none` for a public client, and include `refresh_token` in `grant_types` to be issued refresh tokens.\n- **PKCE.** Required on every authorization request, with `code_challenge_method=S256`; no other method is accepted. The only grants are `authorization_code` and `refresh_token`.\n- **Resource.** Send `resource=https://api.kawaa.com` for this API (RFC 8707). Without it the token is issued for the MCP server, `https://api.kawaa.com/mcp`; any other value is refused with `invalid_target`.\n- **Scopes.** With no `scope` parameter the request is for `verification:write verification:read account:read analytics:read`. Scopes Kawaa does not define, and scopes a connected app can never hold, are dropped rather than refused; if none remain the request is refused with `invalid_scope`. The person approving can untick any scope, and the token response's `scope` states what was granted. `account:write`, `account:delete`, `billing:write`, `team:write`, `security:write` and `white_label:write` are never granted, so the operations that need them do not list this scheme, and neither do those marked `x-kawaa-unscoped-only`. As for API keys, `<area>:write` also satisfies `<area>:read`.\n- **Tokens.** An access token is `evo_` followed by 40 alphanumeric characters and lasts one hour (`expires_in: 3600`); after that the API answers 401 `OAUTH_TOKEN_EXPIRED`. A refresh token is `evr_` followed by 48 alphanumeric characters and is never accepted as a bearer credential. Every refresh returns a new refresh token: the three most recent stay valid, and presenting an older one ends the connection. A refresh may ask for fewer scopes than the connection holds, never more. A connection that is not refreshed for 30 days ends. Revoke at `https://api.kawaa.com/oauth/revoke` (RFC 7009).",
        "flows": {
          "authorizationCode": {
            "authorizationUrl": "https://api.kawaa.com/oauth/authorize",
            "tokenUrl": "https://api.kawaa.com/oauth/token",
            "refreshUrl": "https://api.kawaa.com/oauth/token",
            "scopes": {
              "verification:read": "Read verification results and jobs this account has already paid for. Spends nothing.",
              "verification:write": "Anything that spends verification credits: verifying an address, a batch or a file, finding a business email, and the domain and address-history lookups, which are charged too.",
              "lists:read": "Read auto-clean lists and their run history.",
              "lists:write": "Create, change, delete and run auto-clean lists. Scheduled runs spend credits.",
              "deliverability:read": "Read blacklist, DMARC, inbox-placement and warmup state.",
              "deliverability:write": "Run deliverability checks and manage monitors, inbox tests and warmup campaigns.",
              "compose:read": "Read AI composition history and templates.",
              "compose:write": "Generate and improve email copy with AI. Spends credits.",
              "exports:read": "Read exports, export schedules and data-sharing settings.",
              "exports:write": "Create and delete exports, schedules and destinations. Exported files contain customer addresses.",
              "webhooks:read": "Read webhook endpoints and their delivery logs.",
              "webhooks:write": "Create, change and delete webhook endpoints, and rotate their signing secrets.",
              "integrations:read": "Read connected integrations and their status.",
              "integrations:write": "Connect and disconnect integrations, and push verification into them.",
              "analytics:read": "Read usage, analytics and tracking figures.",
              "analytics:write": "Manage usage alerts, and create or permanently delete tracking pixels and links. Deleting one breaks the tracking already embedded in sent mail.",
              "account:read": "Read the account profile, credit balance and notifications.",
              "billing:read": "Read the subscription, invoices, payment methods and billing address.",
              "team:read": "Read the team, its members and its invitations.",
              "security:read": "Read security settings, sessions, login history and the audit log.",
              "white_label:read": "Read white-label configuration and sub-accounts."
            }
          }
        }
      }
    },
    "parameters": {
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "description": "Makes a charging call safe to retry: a repeat under the same key replays the original outcome instead of buying the work again. **The window depends on the route** — the receipt behind it expires, and after that the same key is a new paid request. It is 30 days for a deliverability check, 24 hours for an activity lookup, and 15 minutes for a bulk find. What a replay returns also differs: a batch replays its JOB, an activity lookup replays its RESULT. A different request under the same key is 409 IDEMPOTENCY_KEY_CONFLICT, before any charge.\n\n**How long a key stays safe differs per route, and for the verification routes it depends on the plan.** `POST /v1/verify`, `/v1/verify/batch` and `/v1/verify/file` stamp their receipts with the account's data-retention period (`keyedChargeTtl(getRetentionTtlSeconds(plan), job)`), which is never less than **30 days** and longer on higher plans: `getDataRetentionDays` is `max(MIN_DATA_RETENTION_DAYS, advertised)` and that floor is 30, so a Free plan advertising 7-day retention still protects a verification key for 30. Once it expires the same key claims a fresh target and creates another paid job. `POST /v1/deliverability/check` keeps its receipt 30 days for the deliverability check, the activity lookups about 24 hours, and `POST /v1/find/bulk` 15 minutes.\n\nRetry with the key you have rather than minting a new one. A key is not a refund — it prevents a second charge, it does not undo the first — but it stays good far longer than the shortest advertised retention suggests, and dropping one early is how a retry becomes a second paid job.\n\n**The characters are checked after trimming.** `readIdempotencyKey` first applies `String.trim()` to the header; the resulting key must be 1-255 printable ASCII characters (`0x21`-`0x7e`), with no internal spaces or anything outside that range. Leading and trailing whitespace is accepted and is not part of the key. Because OpenAPI constraints apply to the raw header rather than the post-trim value, this schema deliberately has no `minLength`, `maxLength`, or `pattern`; those constraints would make generated clients reject values the API accepts. Invalid post-trim values get 400 before the route starts. A UUID is the obvious choice. Sending the header twice with different raw values is also a 400.",
        "schema": {
          "type": "string"
        }
      },
      "Limit": {
        "name": "limit",
        "in": "query",
        "description": "Page size, 1 to 100. A malformed, fractional, zero, negative or larger value is a 400 rather than silently clamped. Every response echoes the page size it applied.",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 100
        }
      },
      "JobId": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "The job identifier, a UUID.",
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "WebhookId": {
        "name": "id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string"
        }
      }
    },
    "headers": {
      "RequestId": {
        "description": "The identifier for this request. Quote it to support.",
        "schema": {
          "type": "string"
        }
      },
      "RetryAfter": {
        "description": "Seconds to wait before retrying. `error.retry_after_seconds` in the body carries the same number.",
        "schema": {
          "type": "integer"
        }
      },
      "RateLimitLimit": {
        "description": "Requests allowed in the window.",
        "schema": {
          "type": "integer"
        }
      },
      "RateLimitRemaining": {
        "description": "Requests left in the window.",
        "schema": {
          "type": "integer"
        }
      },
      "RateLimitReset": {
        "description": "Unix time at which the window resets.",
        "schema": {
          "type": "integer"
        }
      }
    },
    "schemas": {
      "Envelope": {
        "type": "object",
        "description": "Every successful response is `{ \"success\": true, \"data\": … }`.",
        "properties": {
          "success": {
            "const": true
          },
          "data": {
            "type": "object"
          }
        },
        "required": [
          "success",
          "data"
        ]
      },
      "ErrorEnvelope": {
        "type": "object",
        "description": "Every failure is `{ \"success\": false, \"error\": { … } }`. Branch on `error.code`, not on the message. The retry fields are optional and are left out, never guessed, when the server does not know: `retryable` says whether repeating the identical request can succeed, `retry_after_seconds` how long to wait, and on a 5xx (or a refusal decided after the charge) from a route that charges credits one of `retry_requires_same_idempotency_key` or `retry_may_charge_again` says how to repeat it without paying twice.",
        "properties": {
          "success": {
            "const": false
          },
          "error": {
            "type": "object",
            "properties": {
              "code": {
                "$ref": "#/components/schemas/ErrorCode"
              },
              "message": {
                "type": "string"
              },
              "request_id": {
                "type": "string"
              },
              "retryable": {
                "type": "boolean",
                "description": "Whether the identical request, sent again after a wait with nothing else changed, can succeed. `true` for rate limiting and transient failures; `false` when the request, the credential, the plan, the balance or the resource has to change first. Absent when the answer depends on the case, notably `INTERNAL_ERROR` (an unexpected failure, transient or not) and `CONFLICT`. Decided per code; a few responses override their code, such as a `503` for a feature switched off in this deployment (`false`) or a `400` for a download whose job is still running (`true`)."
              },
              "retry_after_seconds": {
                "type": "integer",
                "minimum": 1,
                "description": "Seconds to wait before retrying, when the server knows. The same number as the `Retry-After` header, which is sent with it."
              },
              "retry_requires_same_idempotency_key": {
                "const": true,
                "description": "Present on a 5xx from a route that charges credits and honours `Idempotency-Key`, for a request that sent one. Repeat it only with the same `Idempotency-Key`: an attempt under the key that was charged is replayed rather than bought again, for as long as the key's receipt lasts, and one that was not is simply run. An instruction, not a claim that this attempt was charged."
              },
              "retry_may_charge_again": {
                "const": true,
                "description": "Present on a 5xx (or a refusal decided after the charge, such as an AI-provider 429 once a composition was paid for) from a route that charges credits, for a request with no `Idempotency-Key` to reuse (the route takes none, or the caller sent none), when the server cannot rule out that this attempt was charged. No key marks a repeat as the same request (some routes forgive an identical repeat for a short window, none promises to), so a retry may be charged a second time. Check `GET /v1/usage/credits/transactions`, which lists each debit with its timestamp and description, to see whether this attempt was charged; `GET /v1/usage` gives the balance but not the per-debit detail that answers the question."
              },
              "granted_scopes": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "Present on an `INSUFFICIENT_SCOPE` sent to an app connected over OAuth: the scopes its access token holds. To ask for the missing scope it signs in again requesting these together with the scopes the message names; some clients (Codex CLI's `--scopes`) replace their whole request with the list they are given."
              }
            },
            "required": [
              "code",
              "message"
            ]
          }
        },
        "required": [
          "success",
          "error"
        ]
      },
      "ErrorCode": {
        "type": "string",
        "description": "The machine-readable reason. NOT a closed set: handlers add codes, and a client that refuses an unfamiliar one rejects ordinary API errors instead of handling them. Branch on the codes you know and fall back to the HTTP status. `examples` lists every code this build's handlers can return in an error envelope — whether passed to `error(...)` or written into a response body as `{code, message}` — including some from routes this document does not describe, because a client can still meet them. `TEAM_DELETION_IN_PROGRESS` is the odd one: it arrives with `202`, on a deletion that is proceeding rather than failing.",
        "examples": [
          "API_KEY_REVOKED",
          "API_KEY_SUSPENDED",
          "AUTOCLEAN_UNAVAILABLE",
          "BAD_REQUEST",
          "BATCH_SIZE_EXCEEDED",
          "CHANNEL_NOT_CONFIGURED",
          "CONFLICT",
          "EMAIL_VERIFICATION_REQUIRED",
          "EXPORT_EXPIRED",
          "FORBIDDEN",
          "GATEWAY_TIMEOUT",
          "HUBSPOT_API_ERROR",
          "HUBSPOT_AUTH_FAILED",
          "HUBSPOT_FORBIDDEN",
          "HUBSPOT_INACTIVE",
          "HUBSPOT_NOT_CONNECTED",
          "HUBSPOT_NOT_INITIALIZED",
          "HUBSPOT_NO_TOKEN",
          "HUBSPOT_RATE_LIMIT_EXCEEDED",
          "HUBSPOT_REAUTH_REQUIRED",
          "HUBSPOT_TOKEN_REFRESH_FAILED",
          "IDEMPOTENCY_KEY_CONFLICT",
          "INSUFFICIENT_CREDITS",
          "INSUFFICIENT_SCOPE",
          "INTEGRATION_NOT_CONFIGURED",
          "INTEGRATION_NOT_ENABLED",
          "INTERNAL_ERROR",
          "INVALID_IDEMPOTENCY_KEY",
          "INVALID_OFFSET",
          "INVALID_OPTION",
          "INVALID_WEBHOOK_SECRET",
          "MAILCHIMP_API_ERROR",
          "MAILCHIMP_FORBIDDEN",
          "MAILCHIMP_INACTIVE",
          "MAILCHIMP_NOT_CONNECTED",
          "MAILCHIMP_NOT_INITIALIZED",
          "MAILCHIMP_RATE_LIMIT_EXCEEDED",
          "MAILCHIMP_REAUTH_REQUIRED",
          "MAILCHIMP_TOKEN_INVALID",
          "METHOD_NOT_ALLOWED",
          "NOT_FOUND",
          "NOT_IMPLEMENTED",
          "NO_VALID_FIELDS",
          "OAUTH_TOKEN_EXPIRED",
          "PASSWORD_SETUP_REQUIRED",
          "PAYLOAD_TOO_LARGE",
          "PAYMENT_REQUIRED",
          "PLAN_LIMIT_EXCEEDED",
          "PLAN_UPGRADE_REQUIRED",
          "QUOTA_ALERTS_NOT_CONFIGURABLE",
          "RATE_LIMITED",
          "REAUTHENTICATION_REQUIRED",
          "RESET_TOKEN_INVALID",
          "SALESFORCE_API_ERROR",
          "SALESFORCE_AUTH_FAILED",
          "SALESFORCE_FORBIDDEN",
          "SALESFORCE_INACTIVE",
          "SALESFORCE_INVALID_CAMPAIGN_ID",
          "SALESFORCE_NOT_CONNECTED",
          "SALESFORCE_NOT_INITIALIZED",
          "SALESFORCE_NO_REFRESH_TOKEN",
          "SALESFORCE_REAUTH_REQUIRED",
          "SALESFORCE_TOKEN_REFRESH_FAILED",
          "SALESFORCE_UNAVAILABLE",
          "SECURITY_SETTINGS_NOT_CONFIGURABLE",
          "SERVICE_UNAVAILABLE",
          "SESSION_EXPIRED",
          "SESSION_NOT_EXCHANGEABLE",
          "SESSION_REVOCATION_INCOMPLETE",
          "SUBSCRIPTION_EXISTS",
          "SUB_ACCOUNT_MANAGED",
          "TEAM_DELETION_IN_PROGRESS",
          "UNAUTHORIZED",
          "WEBHOOK_DNS_TEMPORARY_FAILURE",
          "WEBHOOK_URL_INVALID"
        ]
      },
      "VerificationStatus": {
        "type": "string",
        "description": "The verdict. `catch_all` means the domain accepts mail for every recipient, so this particular mailbox could not be checked — it is neither a yes nor a no. `unknown` means the check could not conclude.",
        "enum": [
          "valid",
          "invalid",
          "risky",
          "unknown",
          "catch_all",
          "disposable",
          "role",
          "spam_trap"
        ]
      },
      "SubStatus": {
        "type": "string",
        "description": "Why the verdict came out as it did. New values can be added without a version change; treat an unfamiliar one as informational. Deliberately NOT a closed enum: the list above is what this build emits, and a new reason ships without a version bump. A generated validator built from a closed set would reject an otherwise valid verification result the day one is added, which is the opposite of the forward compatibility this field promises.",
        "examples": [
          "mailbox_exists",
          "mailbox_reject",
          "catch_all",
          "provider_accept_all",
          "policy_block",
          "greylisted",
          "temporary_failure",
          "connection_failure",
          "dns_only",
          "no_mx",
          "domain_not_found",
          "syntax_error"
        ]
      },
      "VerifyOptions": {
        "type": "object",
        "description": "Only these option names are accepted. Anything else is a 400 naming the supported ones, rather than being ignored while the response reads as applied. Omit `options` to use the defaults. A present `null` is a wrong-type value and is refused with `400 INVALID_OPTION` before anything is verified or charged.",
        "properties": {
          "deep_verify": {
            "type": "boolean",
            "description": "Attempt an SMTP conversation with the receiving server."
          },
          "include_ai": {
            "type": "boolean",
            "description": "Include AI typo detection and catch-all confidence."
          },
          "enrich": {
            "type": "boolean",
            "description": "Include name, gender and country hints where available."
          },
          "skip_cache": {
            "type": "boolean",
            "description": "Ignore a cached result and verify fresh. Charges the full rate."
          },
          "timeout_ms": {
            "type": "number",
            "description": "How long `POST /v1/verify` waits (milliseconds, and not required to be a whole number — `verifyOptionsError` accepts any JSON number and the clamp does not round) for a verdict before answering `status: \"pending\"`. **Normalized, not honoured verbatim**, and normalized rather than refused: the default is 24,000 ms, a positive value below 5,000 is raised to 5,000, and anything above 25,000 is capped at 25,000 — asking for 60,000 gets `pending` after about 25 seconds. Zero and negative numbers fall back to the default. The cap can fall further on a request that has already spent time on auth and credits, because polling must finish inside the gateway's 29-second integration timeout.\n\nNo `minimum` or `maximum` is declared here on purpose: they would make a generated client refuse locally what the API accepts and clamps. A present value that is not a number is the one case that IS refused, with `400 INVALID_OPTION` — nothing is verified and nothing is charged.\n\nA pending answer is not a failure: poll `GET /v1/jobs/{job_id}`.",
            "default": 24000
          }
        },
        "additionalProperties": false
      },
      "VerifyRequest": {
        "type": "object",
        "properties": {
          "email": {
            "type": "string",
            "description": "The address to verify. **Normalized before it is validated**, so this is deliberately not constrained with `format: email`: `normalizeEmail` trims surrounding whitespace, lower-cases the address and punycodes an internationalized domain, and only then does `isValidEmail` run. So `\" User@Example.com \"` and `user@münchen.de` are both accepted and are charged like any other address, while a strict email-format validator would refuse the raw strings. The response echoes the NORMALIZED form — compare against that, not against what you sent."
          },
          "options": {
            "$ref": "#/components/schemas/VerifyOptions"
          },
          "include_activity": {
            "type": "boolean",
            "description": "Attach engagement history and domain reputation to the result. Adds NO charge of its own: the call debits once — 1 credit fresh, 0.5 from cache — whether or not you ask for it."
          }
        },
        "required": [
          "email"
        ],
        "additionalProperties": false
      },
      "VerifyBatchRequest": {
        "type": "object",
        "properties": {
          "emails": {
            "type": "array",
            "items": {
              "type": "string",
              "description": "Not constrained to a valid address on purpose: the endpoint removes malformed entries and reports how many in `invalid_emails_skipped`, which is a supported way to clean a list. Validating them away here would refuse requests the API accepts."
            },
            "minItems": 1,
            "maxItems": 10000,
            "description": "The addresses to verify. `maxItems` here is the ABSOLUTE ceiling (Enterprise); the limit that applies is your plan's — Free 100, Starter 1,000, Professional and Business 5,000, Enterprise 10,000 — and a longer list is **413** `BATCH_SIZE_EXCEEDED` before anything is charged — a capacity refusal, not malformed input, so an integration can branch on the status and split or upgrade rather than treating it as a bad request. `GET /v1/account` reports the plan. At least one entry must be a usable address; a list with none is a 400."
          },
          "webhook_url": {
            "type": "string",
            "description": "Where to POST the completion callback. HTTPS only — `validateWebhookUrl` refuses anything else with 400 WEBHOOK_URL_INVALID. Without a `webhook_secret` the callback is unsigned. Omit the field for no callback; an explicit `null` is a wrong-type value and is refused before the job is created or charged. No scheme `pattern` is declared: `validateWebhookUrlStatic` parses with `new URL()`, which tolerates surrounding whitespace and normalizes the protocol, so `\" https://x\"` and `\"HTTPS://x\"` are both accepted — and an anchored lower-case pattern refused both. It must still be HTTPS; that is checked on the parsed protocol, not on the string you sent."
          },
          "options": {
            "$ref": "#/components/schemas/AsyncVerifyOptions"
          },
          "include_activity": {
            "type": "boolean"
          },
          "webhook_secret": {
            "type": "string",
            "minLength": 1,
            "maxLength": 256,
            "description": "Signs the callback to `webhook_url`. Stored with the job, and every delivery then carries `X-Kawaa-Signature` computed from it. Without it the callback is UNSIGNED and anything that can reach your URL can post a convincing completion. Only meaningful alongside `webhook_url`. It has no meaning on its own: a secret with no `webhook_url` is 400 `INVALID_WEBHOOK_SECRET`, because there is no callback to sign. `dependentRequired` says so above. Omit the field for unsigned callbacks; an explicit `null` is refused with `400 INVALID_WEBHOOK_SECRET` before the job is created or charged."
          }
        },
        "required": [
          "emails"
        ],
        "additionalProperties": false,
        "dependentRequired": {
          "webhook_secret": [
            "webhook_url"
          ]
        }
      },
      "FileUploadUrlRequest": {
        "type": "object",
        "properties": {
          "action": {
            "const": "get_upload_url"
          },
          "filename": {
            "type": "string",
            "description": "The name to store the upload under. **Measured after trimming**: `requireStringField` trims the value before checking its length and the extension, so `\" report.csv \"` is accepted and stored as `report.csv`. No `pattern` or `maxLength` is declared here for that reason — an end-anchored pattern and a raw length check both refuse values this route accepts. The trimmed name must be 1-255 characters and end in `.csv` or `.txt`; `.csv` on its own is a name the handler serves."
          }
        },
        "required": [
          "action",
          "filename"
        ],
        "additionalProperties": false
      },
      "FileProcessRequest": {
        "type": "object",
        "properties": {
          "action": {
            "const": "process"
          },
          "file_key": {
            "type": "string",
            "description": "The key returned by `get_upload_url`. The TRIMMED value must be 1-1,024 characters and must sit under your own upload prefix. **Measured after trimming.** `requireStringField` / `optionalStringField` trim the value before checking its length, so no length bound is declared on the raw string here — a raw `maxLength` refuses a valid value padded with spaces, and a raw `minLength` accepts a whitespace-only one the route then rejects with 400."
          },
          "webhook_url": {
            "type": "string",
            "description": "Where to POST the completion callback. The TRIMMED value must be at most 2,048 characters and must use HTTPS. **Measured after trimming.** `requireStringField` / `optionalStringField` trim the value before checking its length, so no length bound is declared on the raw string here — a raw `maxLength` refuses a valid value padded with spaces, and a raw `minLength` accepts a whitespace-only one the route then rejects with 400. HTTPS only — `validateWebhookUrl` refuses anything else with 400 WEBHOOK_URL_INVALID. Omit the field for no callback; an explicit `null` is refused with 400 before the file is read or credits are charged.\n\n**This callback cannot be signed.** Unlike `POST /v1/verify/batch`, the file route has no `webhook_secret`: this object is closed and the handler's process allow-list is `action`, `file_key`, `webhook_url`, `options`, `include_activity`. So every file-completion callback arrives unsigned and carries no `X-Kawaa-Signature`. Treat it as a hint to go and read `GET /v1/jobs/{job_id}` — not as evidence of anything on its own, since anything that can reach your URL can post the same body. No `format: uri` or anchored scheme `pattern` is declared on this REQUEST field: `validateWebhookUrlStatic` parses with `new URL()`, which tolerates surrounding whitespace and normalizes the protocol, so `\" https://x\"` and `\"HTTPS://x\"` are both accepted while either constraint refuses them. HTTPS is required — checked on the PARSED protocol, not on the string you sent. The response fields keep `format: uri`, because what the server hands back really is a well-formed URI."
          },
          "options": {
            "$ref": "#/components/schemas/AsyncVerifyOptions"
          },
          "include_activity": {
            "type": "boolean"
          }
        },
        "required": [
          "action",
          "file_key"
        ],
        "additionalProperties": false
      },
      "FindRequest": {
        "type": "object",
        "properties": {
          "first_name": {
            "type": "string",
            "maxLength": 64,
            "description": "Must contain at least one letter AFTER normalization, which lower-cases, strips accents (NFD) and then keeps only `a-z`: `José` and `Ñuñez` are fine, `李`, `2024` and `!!` are not. A name that normalizes to nothing generates no candidate addresses, so the API answers `400 first_name and last_name must contain letters (a-z)` and charges nothing. Deliberately not a `pattern` here: the check runs on the normalized form, and a regex over the raw string would refuse names this API accepts."
          },
          "last_name": {
            "type": "string",
            "maxLength": 64,
            "description": "Must contain at least one letter AFTER normalization, which lower-cases, strips accents (NFD) and then keeps only `a-z`: `José` and `Ñuñez` are fine, `李`, `2024` and `!!` are not. A name that normalizes to nothing generates no candidate addresses, so the API answers `400 first_name and last_name must contain letters (a-z)` and charges nothing. Deliberately not a `pattern` here: the check runs on the normalized form, and a regex over the raw string would refuse names this API accepts."
          },
          "domain": {
            "type": "string"
          }
        },
        "required": [
          "first_name",
          "last_name",
          "domain"
        ],
        "additionalProperties": false
      },
      "FindBulkRequest": {
        "type": "object",
        "properties": {
          "domain": {
            "type": "string"
          },
          "people": {
            "type": "array",
            "minItems": 1,
            "items": {
              "type": "object",
              "properties": {
                "first_name": {
                  "type": "string",
                  "maxLength": 64
                },
                "last_name": {
                  "type": "string",
                  "maxLength": 64
                }
              },
              "required": [
                "first_name",
                "last_name"
              ],
              "additionalProperties": false
            },
            "maxItems": 50,
            "description": "Up to 50 people per request; more is a 400 before any lookup or charge."
          }
        },
        "required": [
          "domain",
          "people"
        ],
        "additionalProperties": false
      },
      "VerificationResult": {
        "type": "object",
        "description": "A completed verification.",
        "properties": {
          "job_id": {
            "type": "string",
            "format": "uuid"
          },
          "email": {
            "type": "string",
            "format": "email"
          },
          "status": {
            "$ref": "#/components/schemas/VerificationStatus"
          },
          "sub_status": {
            "$ref": "#/components/schemas/SubStatus"
          },
          "quality_score": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100
          },
          "verification": {
            "type": "object",
            "properties": {
              "syntax_valid": {
                "type": "boolean"
              },
              "domain_exists": {
                "type": "boolean"
              },
              "mx_found": {
                "type": "boolean"
              },
              "mx_records": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "smtp_check": {
                "type": "boolean"
              },
              "catch_all": {
                "type": "boolean"
              },
              "greylisted": {
                "type": "boolean"
              },
              "smtp_code": {
                "type": [
                  "integer",
                  "null"
                ]
              },
              "smtp_message": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "mailbox_exists": {
                "type": "boolean",
                "description": "Read `false` as \"not confirmed\", not \"confirmed absent\": a probe refused at the greeting never asked about the mailbox. `status` and `sub_status` say which happened."
              },
              "provider_accept_all": {
                "type": "boolean",
                "description": "The PROVIDER accepts recipient probes too broadly for an individual mailbox to be confirmed — not the same as `catch_all`, which is the domain's own configuration. Present when the worker could tell. SMTP acceptance here is not evidence the mailbox exists, and a client that treats it as such publishes a deliverability claim nothing measured."
              }
            },
            "required": [
              "syntax_valid",
              "domain_exists",
              "mx_found",
              "catch_all",
              "greylisted"
            ]
          },
          "flags": {
            "type": "object",
            "description": "Independent of the verdict: an address can be valid and also role_account.",
            "properties": {
              "disposable": {
                "type": "boolean"
              },
              "role_account": {
                "type": "boolean"
              },
              "free_provider": {
                "type": "boolean"
              },
              "spam_trap": {
                "type": "boolean"
              },
              "abuse_email": {
                "type": "boolean"
              },
              "honeypot": {
                "type": "boolean"
              },
              "complainer": {
                "type": "boolean"
              },
              "bot_generated": {
                "type": "boolean"
              }
            },
            "required": [
              "disposable",
              "role_account",
              "free_provider",
              "spam_trap",
              "abuse_email",
              "honeypot",
              "complainer",
              "bot_generated"
            ]
          },
          "domain_info": {
            "type": "object",
            "properties": {
              "domain": {
                "type": "string"
              },
              "provider_type": {
                "type": "string"
              },
              "auth_score": {
                "type": "integer"
              },
              "auth_grade": {
                "type": "string"
              },
              "has_spf": {
                "type": "boolean"
              },
              "spf_policy": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "has_dmarc": {
                "type": "boolean"
              },
              "dmarc_policy": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "has_dkim": {
                "type": "boolean"
              }
            }
          },
          "ai": {
            "type": "object",
            "properties": {
              "typo_suggestion": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "A corrected address when one looks likely — `null` when there is no suggestion. This is the field the worker serializes; there is no `suggested_email`."
              },
              "typo_confidence": {
                "type": [
                  "number",
                  "null"
                ],
                "description": "How confident the correction is, when one was made."
              },
              "typo_method": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "How the suggestion was arrived at, as the detector names it. Present whenever `typo_suggestion` is. Treat it as an open set: it is the detector's own label, and a new detector adds a new one.",
                "examples": [
                  "DirectMapping",
                  "KeyboardProximity",
                  "Phonetic",
                  "EditDistance",
                  "AiAnalysis"
                ]
              },
              "catch_all_confidence": {
                "type": [
                  "number",
                  "null"
                ],
                "minimum": 0,
                "maximum": 100,
                "description": "0-100, and only for a domain that accepts everything: how likely this particular mailbox is real despite the catch-all. It is the one piece of evidence that separates two `catch_all` addresses, and it is absent on every other result."
              }
            },
            "description": "Present only when `include_ai` was set AND the worker produced something: it is built when there is a typo suggestion or a catch-all confidence, and omitted otherwise. The four fields below are every field it can carry — `fraud_score` and `patterns` exist in the worker's struct but are never populated, so they never appear."
          },
          "credits_used": {
            "type": "number"
          },
          "credits_remaining": {
            "type": "number"
          },
          "processing_time_ms": {
            "type": "integer"
          },
          "verified_at": {
            "type": "string",
            "format": "date-time"
          },
          "from_cache": {
            "type": "boolean"
          },
          "request_id": {
            "type": "string"
          },
          "idempotent_replay": {
            "type": "boolean"
          },
          "activity": {
            "type": [
              "object",
              "null"
            ],
            "description": "Engagement history, attached only when the request asked for it with `include_activity`. Null when nothing has been shared about the address — which is not a signal about the mailbox."
          },
          "domain_reputation": {
            "type": [
              "object",
              "null"
            ],
            "description": "The sending domain's reputation, attached under the same `include_activity` flag."
          },
          "enrichment": {
            "type": [
              "object",
              "null"
            ],
            "description": "Attached when the request set `options.enrich` and the plan includes data enrichment. Null when nothing is known — which is not a judgement about the address.",
            "properties": {
              "first_name": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "last_name": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "full_name": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "gender": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "country": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "country_code": {
                "type": [
                  "string",
                  "null"
                ]
              }
            }
          },
          "cache_age_seconds": {
            "type": "integer",
            "description": "How old the cached result is, in seconds. Present on every cache hit, beside `from_cache: true`. Without it a result reused from days ago reads exactly like one measured this second."
          },
          "classifier_version": {
            "type": "integer",
            "description": "Which verdict classifier produced this result. Stamped by the worker onto every newly produced result. When the classifier changes a cached result from an older version is never reused — the address is verified afresh — so two results with different values here were not decided by the same rules."
          }
        },
        "required": [
          "email",
          "status",
          "quality_score",
          "verification",
          "flags"
        ]
      },
      "PendingVerification": {
        "type": "object",
        "description": "No verdict yet. Not a result — poll the job.",
        "properties": {
          "job_id": {
            "type": "string",
            "format": "uuid"
          },
          "email": {
            "type": "string",
            "format": "email"
          },
          "status": {
            "const": "pending"
          },
          "message": {
            "type": "string"
          },
          "credits_held": {
            "type": "number",
            "description": "Held, not spent. Refunded by reconciliation if the worker never produces a result."
          },
          "activity": {
            "type": [
              "object",
              "null"
            ],
            "description": "Engagement history, attached only when the request asked for it with `include_activity`. Null when nothing has been shared about the address — which is not a signal about the mailbox."
          },
          "domain_reputation": {
            "type": [
              "object",
              "null"
            ],
            "description": "The sending domain's reputation, attached under the same `include_activity` flag."
          },
          "credits_used": {
            "type": "number",
            "description": "What this call charged. Present on a pending result too — the debit happens before the verdict does."
          },
          "credits_remaining": {
            "type": "number",
            "description": "The balance after that charge."
          },
          "idempotent_replay": {
            "type": "boolean",
            "description": "True when this repeated an earlier `POST /v1/verify` under the same `Idempotency-Key`: the job is the ORIGINAL one and nothing more was charged. **Absent means this request created the job** — the handler adds the field only on a replay. `credits_used: 0` accompanies it, which on its own is ambiguous because a pending answer can also report held rather than spent credits; this is the field that says which. Poll `GET /v1/jobs/{job_id}` either way."
          }
        },
        "required": [
          "job_id",
          "status"
        ]
      },
      "BatchAccepted": {
        "type": "object",
        "description": "What `POST /v1/verify/batch` answers with on acceptance. `unique_emails` is what the job will verify; `credits_used` is what was charged, and the two can differ when SQS delivery is ambiguous — see the fields themselves.",
        "properties": {
          "job_id": {
            "type": "string",
            "format": "uuid"
          },
          "status": {
            "$ref": "#/components/schemas/Job/properties/status"
          },
          "total_submitted": {
            "type": "integer",
            "description": "Addresses in the request."
          },
          "unique_emails": {
            "type": "integer",
            "description": "Addresses CONFIRMED queued — what the job will actually verify. It is not always what was charged: when SQS delivery is ambiguous the handler confirms what it can and reports the rest in `enqueue_unknown`, so this can be lower than `credits_used` and, in the extreme, 0 on a job that charged. Reconcile billing against `credits_used` and `credits_held`, never against this count."
          },
          "duplicates_removed": {
            "type": "integer",
            "description": "Removed before charging — the same address twice costs once."
          },
          "invalid_emails_skipped": {
            "type": "integer",
            "description": "Rejected as malformed and never verified. The results will be shorter than the list you sent."
          },
          "credits_used": {
            "type": "number",
            "description": "0 on an idempotent replay."
          },
          "credits_remaining": {
            "type": "number",
            "description": "Absent when the post-charge balance could not be safely recovered."
          },
          "idempotent_replay": {
            "type": "boolean",
            "description": "True when this returned an earlier submission's job and charged nothing."
          },
          "enqueue_unknown": {
            "type": "integer",
            "description": "How many sends could not be confirmed. Credits for them are HELD, not spent — poll the job until it reconciles."
          },
          "credits_held": {
            "type": "number",
            "description": "Held pending reconciliation of an ambiguous enqueue. Not a settled charge."
          },
          "include_activity": {
            "type": "boolean"
          },
          "message": {
            "type": "string"
          },
          "estimated_time_seconds": {
            "type": "integer",
            "description": "A rough estimate of how long the job will take, at about two seconds per address. An estimate for a progress bar, not a deadline — poll `GET /v1/jobs/{job_id}` for the truth."
          },
          "enqueue_failures": {
            "type": "integer",
            "description": "How many addresses SQS DEFINITIVELY rejected, present only when some were. Distinct from `enqueue_unknown`, which is \"we do not know\". Their credits are refunded where the refund succeeds; where it does not they stay held and `message` says so, so this number beside an unchanged `credits_used` is the case to reconcile rather than to retry."
          }
        },
        "required": [
          "job_id",
          "status",
          "total_submitted",
          "unique_emails",
          "credits_used"
        ]
      },
      "JobSummary": {
        "type": "object",
        "description": "Four buckets, and they are BUCKETS rather than verdicts. `summaryForStatus` in `lambda/job-status/index.ts` is the whole rule: `valid` is exactly `valid`; `invalid` is `invalid` AND `disposable`; `risky` is `risky`, `catch_all`, `role` and `spam_trap`; `unknown` is `unknown` and any row with no stored status. A per-address verdict is in the result row, not here — and `catch_all` inside `risky` is the one worth remembering, because it is inconclusive rather than bad.",
        "properties": {
          "valid": {
            "type": "integer",
            "description": "Exactly `valid`."
          },
          "invalid": {
            "type": "integer",
            "description": "Rows whose verdict is `invalid` OR `disposable`. Building a suppression list from this bucket therefore removes disposable addresses too, which is usually intended."
          },
          "risky": {
            "type": "integer",
            "description": "Rows whose verdict is `risky`, `catch_all`, `role` or `spam_trap`. Mixed: `catch_all` is inconclusive, not bad."
          },
          "unknown": {
            "type": "integer",
            "description": "Rows whose verdict is `unknown`, plus any row with no stored status at all."
          }
        }
      },
      "Job": {
        "type": "object",
        "properties": {
          "job_id": {
            "type": "string",
            "format": "uuid"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "processing",
              "completed",
              "failed",
              "cancelled",
              "enqueue_unknown"
            ],
            "description": "The job's state. `enqueue_unknown` means some sends could not be confirmed and credits are held pending reconciliation — poll until it resolves.\n\n**No operation produces `cancelled`.** There is no cancel route, nothing in the platform writes this status, and the worker never emits it — an accepted job runs until it `completed`s or `failed`s, and no action of yours will put one in this state. Do not offer a Cancel action. Size the batch before you send it: the brakes that exist all act at submission — the plan's per-batch ceiling (`413 BATCH_SIZE_EXCEEDED`), the 10,000 global cap, and the credit balance (`402`). There is none afterwards.\n\n**But keep handling it as terminal if you see it.** It stays in the enum, both GET paths return the stored value unchanged, and a row could carry it from before this was true or from an operator write. A poller must stop on it — the repository SDKs list it in their finished/terminal sets for exactly that reason, and treating it as still-running is the one handling that would break.\n\nTerminal does NOT mean empty: such a job can carry a **partial** result set, and `GET /v1/jobs/{id}?include_results=true` returns it — the handler gates on `processed_emails > 0`, not on status, so the addresses it did check come back. Read `processed_emails` against `total_emails` to see how far it got, and do not discard those results. (`GET /v1/jobs/{id}/download` is stricter and refuses any non-`completed` job, so use the results endpoint for one of these.) (#1321)"
          },
          "total_emails": {
            "type": "integer"
          },
          "processed_emails": {
            "type": "integer"
          },
          "progress_percent": {
            "type": "integer"
          },
          "summary": {
            "$ref": "#/components/schemas/JobSummary"
          },
          "results": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/VerificationResult"
            }
          },
          "next_key": {
            "type": [
              "string",
              "null"
            ],
            "description": "Cursor for the next page of results."
          },
          "has_more": {
            "type": "boolean"
          },
          "credits_used": {
            "type": "number"
          },
          "credits_refunded": {
            "type": "number"
          },
          "billing_status": {
            "type": "string"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          },
          "completed_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "error_message": {
            "type": [
              "string",
              "null"
            ],
            "description": "Why a terminal job ended as it did, in Kawaa's own customer-safe words — present on failures and on reconciliation outcomes. Without it, `status: \"failed\"` is the whole story a client can tell."
          }
        },
        "required": [
          "job_id",
          "status"
        ]
      },
      "JobPage": {
        "type": "object",
        "properties": {
          "jobs": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/JobListItem"
            },
            "description": "Each row is a `Job` plus `filename`, which is how a file upload is told from a submitted list. The per-job endpoint does not carry it."
          },
          "limit": {
            "type": "integer",
            "description": "The page size that was applied."
          },
          "total": {
            "type": "integer",
            "description": "Every job matching the listing, not just this page."
          },
          "total_is_approximate": {
            "type": "boolean",
            "description": "True when the count stopped at its scan bound, so `total` is a floor."
          },
          "has_more": {
            "type": "boolean"
          },
          "next_offset": {
            "type": [
              "string",
              "null"
            ]
          }
        },
        "required": [
          "jobs",
          "has_more"
        ]
      },
      "ActivityData": {
        "type": "object",
        "properties": {
          "level": {
            "type": "string",
            "enum": [
              "active",
              "inactive",
              "dormant",
              "unknown"
            ],
            "description": "How recently this address was seen active. `inactive` sits between the active and dormant windows; `unknown` means there was nothing to judge from, not that the address is inactive."
          },
          "score": {
            "type": "number"
          },
          "last_activity_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "engagement": {
            "type": "object",
            "properties": {
              "open_count": {
                "type": "integer"
              },
              "click_count": {
                "type": "integer"
              },
              "reply_count": {
                "type": "integer"
              }
            },
            "required": [
              "open_count",
              "click_count",
              "reply_count"
            ]
          },
          "deliverability": {
            "type": "object",
            "properties": {
              "bounce_count": {
                "type": [
                  "integer",
                  "null"
                ]
              },
              "complaint_count": {
                "type": [
                  "integer",
                  "null"
                ]
              }
            },
            "required": [
              "bounce_count",
              "complaint_count"
            ]
          },
          "confidence": {
            "type": "number",
            "description": "0 means there is no shared data for this address — not that it is inactive."
          },
          "data_points": {
            "type": "integer"
          },
          "message": {
            "type": "string",
            "description": "Present only when no activity data is available for a single-address lookup."
          }
        },
        "required": [
          "level",
          "score",
          "last_activity_at",
          "engagement",
          "deliverability",
          "confidence",
          "data_points"
        ]
      },
      "EmailActivity": {
        "type": "object",
        "properties": {
          "email": {
            "type": "string",
            "format": "email",
            "description": "The validated address, echoed unchanged from the path. Malformed or whitespace-padded values are refused before the paid lookup begins."
          },
          "email_hash": {
            "type": "string"
          },
          "activity": {
            "$ref": "#/components/schemas/ActivityData"
          },
          "credits_used": {
            "type": "number"
          },
          "credits_remaining": {
            "type": "number"
          },
          "idempotent_replay": {
            "type": "boolean",
            "description": "True when this repeated an earlier lookup under the same `Idempotency-Key` and therefore charged nothing. **Absent means it was charged** — `activityReceiptResponse` adds the field only on a replay. `credits_used: 0` alone cannot tell you which happened, because a lookup with nothing to bill also reports 0; this is the field that distinguishes them. The receipt lasts about 24 hours."
          }
        },
        "required": [
          "email",
          "activity",
          "credits_used"
        ]
      },
      "DeliverabilityCheck": {
        "type": "object",
        "properties": {
          "domain": {
            "type": "string"
          },
          "sendingIp": {
            "type": [
              "string",
              "null"
            ]
          },
          "score": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100
          },
          "grade": {
            "type": "string"
          },
          "checkedAt": {
            "type": "string",
            "format": "date-time"
          },
          "durationMs": {
            "type": "integer"
          },
          "checks": {
            "type": "object",
            "description": "The raw per-mechanism scoring detail. One entry per mechanism REPORTED, and they do NOT share a shape: `dkim` carries the selectors it found, `mx` the records, `blacklists` the zones and the listings — and `mx` and `ptr` carry no `issues` array at all. Read the named property, not a common map value.\n\n**Optional, and absent on an older stored result.** `projectDeliverabilityResult` tolerates a row with no `checks` bag (`internal.checks ?? {}`) and does not synthesize one, so a check stored before the current shape answers 200 with the projected fields and no `checks`. Read `authentication`, `dns`, `reputation` and `server`, which the projection always produces, and treat this as the raw detail when it happens to be there.\n\n**BIMI and MTA-STS are checked, and they are unscored.** Both initialise `maxScore: 0`, and the scoring loop counts a mechanism only when `maxScore > 0`, so neither can move `score` or `grade` in either direction. They also have no entry in this object. A missing MTA-STS raises nothing at all; a missing BIMI produces a recommendation only when DMARC is already `reject`.",
            "properties": {
              "spf": {
                "type": "object",
                "description": "SPF: whether a record exists, parses, and is not over the lookup limit.",
                "properties": {
                  "status": {
                    "type": "string",
                    "enum": [
                      "pass",
                      "weak",
                      "missing",
                      "invalid",
                      "error",
                      "found",
                      "listed",
                      "skipped"
                    ]
                  },
                  "score": {
                    "type": "number",
                    "description": "What this mechanism contributed to the total."
                  },
                  "maxScore": {
                    "type": "number",
                    "description": "The most it could have contributed. A mechanism that could not be read scores 0 against this, which is why the grade drops for an unreadable check rather than ignoring it."
                  },
                  "record": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "The SPF record as published, or null when there is none."
                  },
                  "issues": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "severity": {
                          "type": "string",
                          "enum": [
                            "error",
                            "warning",
                            "info"
                          ],
                          "description": "How serious the finding is. `error` is what a missing SPF, DMARC, MX or PTR record produces — the common case on a paid check."
                        },
                        "message": {
                          "type": "string"
                        }
                      }
                    }
                  }
                },
                "required": [
                  "status",
                  "score",
                  "maxScore"
                ]
              },
              "dkim": {
                "type": "object",
                "description": "DKIM: which selectors published a key.",
                "properties": {
                  "status": {
                    "type": "string",
                    "enum": [
                      "pass",
                      "weak",
                      "missing",
                      "invalid",
                      "error",
                      "found",
                      "listed",
                      "skipped"
                    ]
                  },
                  "score": {
                    "type": "number",
                    "description": "What this mechanism contributed to the total."
                  },
                  "maxScore": {
                    "type": "number",
                    "description": "The most it could have contributed. A mechanism that could not be read scores 0 against this, which is why the grade drops for an unreadable check rather than ignoring it."
                  },
                  "selectors": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "The selectors a key was found under. Empty means none of the selectors tried resolved — not that DKIM is unused, because Kawaa can only probe the common ones."
                  },
                  "issues": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "severity": {
                          "type": "string",
                          "enum": [
                            "error",
                            "warning",
                            "info"
                          ],
                          "description": "How serious the finding is. `error` is what a missing SPF, DMARC, MX or PTR record produces — the common case on a paid check."
                        },
                        "message": {
                          "type": "string"
                        }
                      }
                    }
                  }
                },
                "required": [
                  "status",
                  "score",
                  "maxScore"
                ]
              },
              "dmarc": {
                "type": "object",
                "description": "DMARC: the published policy and record.",
                "properties": {
                  "status": {
                    "type": "string",
                    "enum": [
                      "pass",
                      "weak",
                      "missing",
                      "invalid",
                      "error",
                      "found",
                      "listed",
                      "skipped"
                    ]
                  },
                  "score": {
                    "type": "number",
                    "description": "What this mechanism contributed to the total."
                  },
                  "maxScore": {
                    "type": "number",
                    "description": "The most it could have contributed. A mechanism that could not be read scores 0 against this, which is why the grade drops for an unreadable check rather than ignoring it."
                  },
                  "record": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "policy": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "The `p=` value: none, quarantine or reject.",
                    "examples": [
                      "none",
                      "quarantine",
                      "reject"
                    ]
                  },
                  "issues": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "severity": {
                          "type": "string",
                          "enum": [
                            "error",
                            "warning",
                            "info"
                          ],
                          "description": "How serious the finding is. `error` is what a missing SPF, DMARC, MX or PTR record produces — the common case on a paid check."
                        },
                        "message": {
                          "type": "string"
                        }
                      }
                    }
                  }
                },
                "required": [
                  "status",
                  "score",
                  "maxScore"
                ]
              },
              "mx": {
                "type": "object",
                "description": "MX: whether the domain can receive mail at all. No `issues` array on this one.",
                "properties": {
                  "status": {
                    "type": "string",
                    "enum": [
                      "pass",
                      "weak",
                      "missing",
                      "invalid",
                      "error",
                      "found",
                      "listed",
                      "skipped"
                    ]
                  },
                  "score": {
                    "type": "number",
                    "description": "What this mechanism contributed to the total."
                  },
                  "maxScore": {
                    "type": "number",
                    "description": "The most it could have contributed. A mechanism that could not be read scores 0 against this, which is why the grade drops for an unreadable check rather than ignoring it."
                  },
                  "records": {
                    "type": "array",
                    "description": "The MX hosts, in the order DNS returned them.",
                    "items": {
                      "type": "object",
                      "properties": {
                        "priority": {
                          "type": "integer"
                        },
                        "host": {
                          "type": "string"
                        }
                      },
                      "required": [
                        "priority",
                        "host"
                      ]
                    }
                  }
                },
                "required": [
                  "status",
                  "score",
                  "maxScore"
                ]
              },
              "ptr": {
                "type": "object",
                "description": "PTR: reverse DNS for `sending_ip`. Only meaningful when `sending_ip` was sent. No `issues` array on this one.",
                "properties": {
                  "status": {
                    "type": "string",
                    "enum": [
                      "pass",
                      "weak",
                      "missing",
                      "invalid",
                      "error",
                      "found",
                      "listed",
                      "skipped"
                    ]
                  },
                  "score": {
                    "type": "number",
                    "description": "What this mechanism contributed to the total."
                  },
                  "maxScore": {
                    "type": "number",
                    "description": "The most it could have contributed. A mechanism that could not be read scores 0 against this, which is why the grade drops for an unreadable check rather than ignoring it."
                  },
                  "record": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "The reverse-DNS name for the sending IP, when one was given. Null when no `sending_ip` was supplied or the lookup found nothing."
                  }
                },
                "required": [
                  "status",
                  "score",
                  "maxScore"
                ]
              },
              "blacklists": {
                "type": "object",
                "description": "Public blocklists. Read `checked` and `inconclusive` before treating an empty `listed` as a clean bill of health.",
                "properties": {
                  "status": {
                    "type": "string",
                    "enum": [
                      "pass",
                      "weak",
                      "missing",
                      "invalid",
                      "error",
                      "found",
                      "listed",
                      "skipped"
                    ]
                  },
                  "score": {
                    "type": "number",
                    "description": "What this mechanism contributed to the total."
                  },
                  "maxScore": {
                    "type": "number",
                    "description": "The most it could have contributed. A mechanism that could not be read scores 0 against this, which is why the grade drops for an unreadable check rather than ignoring it."
                  },
                  "listed": {
                    "type": "array",
                    "description": "The zones that answered \"listed\". Empty is the good case.",
                    "items": {
                      "type": "object",
                      "properties": {
                        "name": {
                          "type": "string"
                        },
                        "zone": {
                          "type": "string"
                        },
                        "severity": {
                          "type": "string",
                          "enum": [
                            "critical",
                            "high",
                            "medium",
                            "low"
                          ]
                        }
                      },
                      "required": [
                        "name",
                        "zone"
                      ]
                    }
                  },
                  "checked": {
                    "type": "integer",
                    "description": "Zones that gave a definitive answer either way."
                  },
                  "inconclusive": {
                    "type": "integer",
                    "description": "Zones that were asked and answered nothing usable — a timeout, a refusal, a SERVFAIL. NOT a pass: an unanswered zone is not evidence of a clean record, and it contributes neither a pass nor its share of `maxScore`."
                  },
                  "skipped": {
                    "type": "integer",
                    "description": "Zones that were never asked, e.g. IP zones when no `sending_ip` was given."
                  },
                  "total": {
                    "type": "integer",
                    "description": "Zones in the list. `checked + inconclusive + skipped`."
                  },
                  "issues": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "severity": {
                          "type": "string",
                          "enum": [
                            "error",
                            "warning",
                            "info"
                          ],
                          "description": "How serious the finding is. `error` is what a missing SPF, DMARC, MX or PTR record produces — the common case on a paid check."
                        },
                        "message": {
                          "type": "string"
                        }
                      }
                    }
                  }
                },
                "required": [
                  "status",
                  "score",
                  "maxScore"
                ]
              }
            }
          },
          "check_id": {
            "type": "string",
            "description": "The saved check. Fetch it again with `GET /v1/deliverability/checks/{id}` — free, and the only way back to a result whose response was lost."
          },
          "credits_used": {
            "type": "number",
            "description": "What this call actually charged: 10 for a fresh check, 0 for a cached one or an idempotent replay. Read this rather than the price list."
          },
          "credits_remaining": {
            "type": "number",
            "description": "Balance after the charge. Omitted when it could not be read — its absence is not a zero balance."
          },
          "cached": {
            "type": "boolean",
            "description": "Present and true when this reused a check from the last 24 hours, which costs nothing. Send `refresh: true` to measure again."
          },
          "idempotent_replay": {
            "type": "boolean",
            "description": "Present and true when this returned an earlier call's result under the same `Idempotency-Key`, and charged nothing. Distinct from `cached`: a replay can return a FRESH check that was already paid for."
          },
          "issues": {
            "type": "array",
            "description": "Every finding, across every mechanism, severity-sorted. This is the list to render: the per-mechanism `issues` arrays do not carry MX, PTR or blocklist findings at all, so rebuilding this from `checks` loses exactly the problems that stop mail being delivered.",
            "items": {
              "type": "object",
              "properties": {
                "check": {
                  "type": "string",
                  "description": "Which mechanism produced it.",
                  "examples": [
                    "spf",
                    "dkim",
                    "dmarc",
                    "mx",
                    "ptr",
                    "blacklists"
                  ]
                },
                "severity": {
                  "type": "string",
                  "enum": [
                    "error",
                    "warning",
                    "info"
                  ]
                },
                "message": {
                  "type": "string"
                }
              },
              "required": [
                "severity",
                "message"
              ]
            }
          },
          "recommendations": {
            "type": "array",
            "description": "What to change, in the order worth doing it. The remediation half of the 10 credits — a score with no recommendations is a grade without an answer. Empty on a domain with nothing to fix.",
            "items": {
              "type": "object",
              "description": "One thing to change, and how.",
              "properties": {
                "priority": {
                  "type": "string",
                  "enum": [
                    "high",
                    "medium",
                    "low"
                  ],
                  "description": "Do the `high` ones first: they are the missing records that cause mail to be rejected outright, rather than the weak ones that cost reputation slowly."
                },
                "category": {
                  "type": "string",
                  "description": "Which part of the setup this is about, e.g. `Authentication`, `Reputation`, `Infrastructure`."
                },
                "issue": {
                  "type": "string",
                  "description": "What is wrong, in one line."
                },
                "fix": {
                  "type": "string",
                  "description": "What to change, concretely — usually the record to publish and roughly what to put in it."
                }
              },
              "required": [
                "priority",
                "category",
                "issue",
                "fix"
              ]
            }
          },
          "checked_at": {
            "type": "string",
            "description": "When the check ran. Falls back to the Unix epoch on an older cached row that stored none — a timestamp of `1970-01-01` means \"not recorded\", not \"checked in 1970\".",
            "format": "date-time"
          },
          "dns": {
            "type": "object",
            "description": "The mail exchangers, as the projection reports them.",
            "properties": {
              "mx_records": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "priority": {
                      "type": "integer"
                    },
                    "host": {
                      "type": "string"
                    },
                    "ip": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "Null unless the host was resolved during the check — usually null."
                    }
                  },
                  "required": [
                    "priority",
                    "host",
                    "ip"
                  ]
                }
              },
              "has_valid_mx": {
                "type": "boolean",
                "description": "True only when the MX check passed AND at least one record came back."
              },
              "mx_score": {
                "type": "number",
                "description": "The MX contribution, normalized to 100."
              }
            },
            "required": [
              "mx_records",
              "has_valid_mx",
              "mx_score"
            ]
          },
          "authentication": {
            "type": "object",
            "description": "SPF, DKIM and DMARC as three comparable results. This is the half of the answer a sender acts on; `recommendations` says what to do about it.",
            "properties": {
              "spf": {
                "type": "object",
                "properties": {
                  "status": {
                    "type": "string",
                    "description": "The projection's three-value status: `pass`, `fail`, or `missing`. `missing` means no record was found and is not the same as `fail`, which means one was found and did not hold up.",
                    "enum": [
                      "pass",
                      "fail",
                      "missing"
                    ]
                  },
                  "record": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "The published record, or null when none was found."
                  },
                  "score": {
                    "type": "number",
                    "description": "This mechanism's contribution, already normalized to 100."
                  },
                  "issues": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "What is wrong with this record, in sentences. Empty when nothing is."
                  }
                },
                "required": [
                  "status",
                  "record",
                  "score",
                  "issues"
                ]
              },
              "dkim": {
                "type": "object",
                "properties": {
                  "status": {
                    "type": "string",
                    "description": "The projection's three-value status: `pass`, `fail`, or `missing`. `missing` means no record was found and is not the same as `fail`, which means one was found and did not hold up.",
                    "enum": [
                      "pass",
                      "fail",
                      "missing"
                    ]
                  },
                  "selectors_found": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Selectors that answered. Empty is the ordinary shape for a domain with no DKIM — the check probes a fixed list and cannot enumerate selectors."
                  },
                  "score": {
                    "type": "number",
                    "description": "This mechanism's contribution, already normalized to 100."
                  },
                  "issues": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "What is wrong with this record, in sentences. Empty when nothing is."
                  }
                },
                "required": [
                  "status",
                  "selectors_found",
                  "score",
                  "issues"
                ]
              },
              "dmarc": {
                "type": "object",
                "properties": {
                  "status": {
                    "type": "string",
                    "description": "The projection's three-value status: `pass`, `fail`, or `missing`. `missing` means no record was found and is not the same as `fail`, which means one was found and did not hold up.",
                    "enum": [
                      "pass",
                      "fail",
                      "missing"
                    ]
                  },
                  "record": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "policy": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "The `p=` value: `none`, `quarantine` or `reject`. `none` is published-but-not-enforcing, which scores worse than `quarantine` and better than nothing at all."
                  },
                  "score": {
                    "type": "number",
                    "description": "This mechanism's contribution, already normalized to 100."
                  },
                  "issues": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "What is wrong with this record, in sentences. Empty when nothing is."
                  }
                },
                "required": [
                  "status",
                  "record",
                  "policy",
                  "score",
                  "issues"
                ]
              }
            },
            "required": [
              "spf",
              "dkim",
              "dmarc"
            ]
          },
          "reputation": {
            "type": "object",
            "description": "Blacklist standing. The counts are separate on purpose: a zone that did not answer is not a zone that cleared you.",
            "properties": {
              "score": {
                "type": [
                  "number",
                  "null"
                ],
                "description": "**Null when no blacklist zone answered at all** — there is no verdict to score, and a zero would read as \"listed everywhere\". Not a measurement of nothing."
              },
              "blacklists_checked": {
                "type": "integer",
                "description": "Zones that returned a usable answer."
              },
              "blacklists_inconclusive": {
                "type": "integer",
                "description": "Zones that answered with something that could not be read as listed or not listed. **They are removed from the grade rather than scored against it**: `checkBlacklists` scales both the section score and its maximum by the consulted proportion, so an unreadable zone neither penalises the domain nor silently counts as clean — the reputation section simply carries less weight. That is why `score` here can be null when nothing answered at all."
              },
              "blacklists_skipped": {
                "type": "integer",
                "description": "Zones not queried on this run."
              },
              "blacklists_total": {
                "type": "integer",
                "description": "Zones in the list. `checked + inconclusive + skipped` should account for it."
              },
              "blacklists_listed": {
                "type": "integer",
                "description": "Zones that reported the domain or its IP as listed."
              },
              "listed_on": {
                "type": "array",
                "description": "Which ones, and how much each matters. Empty when `blacklists_listed` is 0.",
                "items": {
                  "type": "object",
                  "properties": {
                    "name": {
                      "type": "string"
                    },
                    "severity": {
                      "type": "string",
                      "description": "How much a listing there costs, worst first.",
                      "enum": [
                        "critical",
                        "high",
                        "medium",
                        "low"
                      ]
                    }
                  },
                  "required": [
                    "name",
                    "severity"
                  ]
                }
              }
            },
            "required": [
              "score",
              "blacklists_checked",
              "blacklists_inconclusive",
              "blacklists_skipped",
              "blacklists_total",
              "blacklists_listed",
              "listed_on"
            ]
          },
          "server": {
            "type": "object",
            "description": "What a probe of the receiving server would have found. **Four of these five are always null**: `accepts_mail`, `response_time_ms`, `supports_tls` and `tls_version` are hard-coded null in the projection — no SMTP banner, TLS or acceptance probe runs on this route, with or without a `sending_ip`. Only `reverse_dns` is ever populated, from the PTR check, and only when a `sending_ip` was supplied.\n\nNull here means \"not measured\", never \"no\". The fields exist because the shape is shared with a probe that may be added later; today they are placeholders and must not be reported as evidence about the server.",
            "properties": {
              "accepts_mail": {
                "type": [
                  "boolean",
                  "null"
                ],
                "description": "Always null today — no acceptance probe runs. Not \"the server refuses mail\"."
              },
              "response_time_ms": {
                "type": [
                  "number",
                  "null"
                ],
                "description": "Always null today — nothing is timed."
              },
              "supports_tls": {
                "type": [
                  "boolean",
                  "null"
                ],
                "description": "Always null today. Not \"no TLS\": nothing connected to find out."
              },
              "tls_version": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Always null today, for the same reason as `supports_tls`."
              },
              "reverse_dns": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "The PTR record for the `sending_ip`, and the only field here that is ever populated. Null when no `sending_ip` was supplied, because `checkPtr` is handed the IP directly and returns `status: \"skipped\"` for null — it never resolves an address from the MX records."
              }
            },
            "required": [
              "accepts_mail",
              "response_time_ms",
              "supports_tls",
              "tls_version",
              "reverse_dns"
            ]
          },
          "spam_assessment": {
            "type": "object",
            "description": "The same evidence again, expressed as how likely mail from this domain is to be filtered.",
            "properties": {
              "score": {
                "type": "number",
                "description": "How likely mail from this domain is to be filtered, 0-100, where **higher is worse**. It is the INVERSE of the top-level `score`: `100 - score`, clamped. A domain scoring 90 for deliverability carries a spam score of 10. Do not compare it against the top-level number or apply the same thresholds to it."
              },
              "spam_likelihood": {
                "type": "string",
                "description": "A band, not a prediction about any one message. **Derived from the DELIVERABILITY score, not from `score` beside it**: `low` at 80 and above, `medium` from 60, `high` below. Because that sibling is the inverse, applying these numbers to it reads every domain backwards — a spam score of 10 is the best case, not the worst. Read `spam_likelihood` and leave the arithmetic alone.",
                "enum": [
                  "low",
                  "medium",
                  "high"
                ]
              },
              "factors": {
                "type": "array",
                "description": "What pushed the score each way, including the things that helped.",
                "items": {
                  "type": "object",
                  "properties": {
                    "factor": {
                      "type": "string",
                      "description": "What was looked at, e.g. `SPF`, `DKIM`, `DMARC`, `MX`."
                    },
                    "impact": {
                      "type": "string",
                      "description": "Which way it pushed. `neutral` means it was assessed and changed nothing — not that it was skipped.",
                      "enum": [
                        "positive",
                        "negative",
                        "neutral"
                      ]
                    },
                    "description": {
                      "type": "string",
                      "description": "Why, in one sentence."
                    }
                  },
                  "required": [
                    "factor",
                    "impact",
                    "description"
                  ]
                }
              }
            },
            "required": [
              "score",
              "spam_likelihood",
              "factors"
            ]
          }
        },
        "required": [
          "domain",
          "score",
          "grade",
          "checked_at",
          "dns",
          "authentication",
          "reputation",
          "server",
          "spam_assessment",
          "recommendations"
        ]
      },
      "ApiKeySummary": {
        "type": "object",
        "properties": {
          "key_id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "status": {
            "type": "string"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "last_used_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "key_preview": {
            "type": "string",
            "description": "The last four characters, never the secret."
          },
          "scopes": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string",
              "enum": [
                "verification:read",
                "verification:write",
                "lists:read",
                "lists:write",
                "deliverability:read",
                "deliverability:write",
                "compose:read",
                "compose:write",
                "exports:read",
                "exports:write",
                "webhooks:read",
                "webhooks:write",
                "integrations:read",
                "integrations:write",
                "analytics:read",
                "analytics:write",
                "account:read",
                "account:write",
                "account:delete",
                "billing:read",
                "billing:write",
                "team:read",
                "team:write",
                "security:read",
                "security:write",
                "white_label:read",
                "white_label:write"
              ]
            },
            "description": "What this key may reach. **`null` means UNRESTRICTED** — every key created without a `scopes` array is, and that is the default. Always present, so `null` is a fact about the key rather than a field an older build omitted."
          }
        },
        "required": [
          "key_id",
          "name",
          "status",
          "scopes"
        ]
      },
      "Account": {
        "type": "object",
        "properties": {
          "account": {
            "type": "object",
            "properties": {
              "user_id": {
                "type": "string"
              },
              "email": {
                "type": "string",
                "format": "email"
              },
              "name": {
                "type": "string",
                "description": "Optional: the handler assigns `name: user.name`, and `UserRecord.name` is itself optional, so JSON serialization omits the property entirely for an account that never stored one."
              },
              "plan": {
                "type": "string",
                "description": "The account's own plan. Never `sub_account` — a white-label sub-account's key gets the SubAccount shape instead, which is a different object.",
                "not": {
                  "const": "sub_account"
                }
              },
              "status": {
                "type": "string"
              },
              "created_at": {
                "description": "The account creation time. Legacy rows that predate this field are returned as the empty string; the handler does not invent a timestamp.",
                "anyOf": [
                  {
                    "type": "string",
                    "format": "date-time"
                  },
                  {
                    "const": ""
                  }
                ]
              },
              "is_admin": {
                "type": "boolean"
              },
              "timezone": {
                "type": "string"
              },
              "email_delivery": {
                "type": "object",
                "description": "Whether Kawaa is still willing to email this account. Always present, including the healthy case.",
                "properties": {
                  "deliverable": {
                    "type": "boolean"
                  },
                  "reason": {
                    "type": "string"
                  },
                  "since": {
                    "type": "string",
                    "format": "date-time"
                  },
                  "detail": {
                    "type": "string"
                  }
                },
                "required": [
                  "deliverable"
                ]
              }
            },
            "required": [
              "user_id",
              "email",
              "plan",
              "status",
              "created_at",
              "is_admin",
              "email_delivery"
            ]
          },
          "credits": {
            "type": "object",
            "properties": {
              "balance": {
                "type": "number",
                "description": "Fractional: a cached verification costs 0.5."
              },
              "used_this_month": {
                "type": "number"
              },
              "monthly_allowance": {
                "type": "number"
              },
              "next_reset": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "date-time"
              },
              "allowance_basis": {
                "type": "string",
                "description": "Whether the allowance renews. `next_reset` alone cannot say so — it is also absent on a paid plan whose billing period has not landed yet."
              }
            }
          },
          "usage": {
            "type": "object"
          },
          "api_keys": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ApiKeySummary"
            }
          },
          "recent_jobs": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Job"
            }
          },
          "rate_limits": {
            "type": "object",
            "properties": {
              "requests_per_minute": {
                "type": "integer"
              },
              "enforced": {
                "type": "boolean"
              }
            }
          },
          "partial": {
            "type": "object",
            "description": "Names sections that could NOT BE LOADED. Their values are placeholders, not facts — do not render an empty state for a flagged section.",
            "properties": {
              "usage": {
                "const": true
              },
              "api_keys": {
                "const": true
              },
              "recent_jobs": {
                "const": true
              }
            }
          },
          "withheld": {
            "type": "object",
            "description": "Names sections this API key's SCOPES DO NOT COVER. They come back empty; an empty api_keys here never means the account has no keys. `usage: true` means `usage.current_month` is ZERO-FILLED rather than measured, so a client that ignores this marker publishes a fabricated month of no activity.",
            "properties": {
              "usage": {
                "const": true
              },
              "api_keys": {
                "const": true
              },
              "recent_jobs": {
                "const": true
              }
            }
          }
        },
        "required": [
          "account",
          "credits"
        ]
      },
      "AsyncVerifyOptions": {
        "type": "object",
        "description": "Verification options for an ASYNCHRONOUS submission. Deliberately not `VerifyOptions`: that schema permits `timeout_ms`, which these routes refuse with 400 INVALID_OPTION because there is no synchronous wait to bound. Omit `options` to use the defaults; a present `null` is refused with the same code before work starts or credits are charged.",
        "properties": {
          "deep_verify": {
            "type": "boolean"
          },
          "include_ai": {
            "type": "boolean"
          },
          "enrich": {
            "type": "boolean"
          },
          "skip_cache": {
            "type": "boolean"
          }
        },
        "additionalProperties": false
      },
      "JobResultsInline": {
        "type": "object",
        "description": "`format=json` under the inline threshold. NOT the standard envelope: this route answers with the object itself, so there is no `success` or `data` wrapper to unwrap. Carries the completeness verdict — read `complete` before treating `results` as the job.",
        "properties": {
          "job_id": {
            "type": "string",
            "format": "uuid"
          },
          "status": {
            "$ref": "#/components/schemas/Job/properties/status"
          },
          "result_count": {
            "type": "integer"
          },
          "complete": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Whether this export is the job's whole result set. `true` when the rows returned reach the job's own `processed_emails`; `false` when they fall short; `null` when the job row carries no count to compare against, which is \"not verified\" and never a stand-in for \"complete\". The export is read from an eventually-consistent index, so a job that has just finished can be exported before its last rows are visible — a short file that used to be reported as the job's results with nothing saying otherwise (#1339). Measured on the UNFILTERED read, so a `filter=` export inherits the verdict of the read it was narrowed from."
          },
          "expected_rows": {
            "type": [
              "integer",
              "null"
            ],
            "description": "The job's `processed_emails` — how many addresses it finished — which is what the export was counted against. `null` when the job row carries none."
          },
          "rows_missing": {
            "type": [
              "integer",
              "null"
            ],
            "description": "`expected_rows` minus the rows this read returned, or `0` when nothing is missing. `null` when completeness was not verified. Never negative: a read returning more than `processed_emails` is not short."
          },
          "message": {
            "type": "string",
            "description": "Present only when `complete` is not `true`. Says what is missing and what to do — normally request the download again in a few seconds, because the index catches up. A persistent shortfall means those rows aged out of the plan's data-retention window."
          },
          "results": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/DownloadedResult"
            }
          }
        },
        "required": [
          "job_id",
          "status",
          "result_count",
          "results",
          "complete",
          "expected_rows",
          "rows_missing"
        ]
      },
      "JobResultsLink": {
        "type": "object",
        "description": "Returned instead of the rows when the result set is over the inline threshold — whatever `format` says, so a large CSV export is announced through this object. Also unwrapped. The link is time-limited and anyone holding it can read the results, which contain customer addresses. Read `complete` before treating the file as the job: the link is minted for whatever the index returned, including a short read.",
        "properties": {
          "download_url": {
            "type": "string",
            "format": "uri"
          },
          "format": {
            "type": "string",
            "enum": [
              "json",
              "csv"
            ]
          },
          "result_count": {
            "type": "integer"
          },
          "complete": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Whether this export is the job's whole result set. `true` when the rows returned reach the job's own `processed_emails`; `false` when they fall short; `null` when the job row carries no count to compare against, which is \"not verified\" and never a stand-in for \"complete\". The export is read from an eventually-consistent index, so a job that has just finished can be exported before its last rows are visible — a short file that used to be reported as the job's results with nothing saying otherwise (#1339). Measured on the UNFILTERED read, so a `filter=` export inherits the verdict of the read it was narrowed from."
          },
          "expected_rows": {
            "type": [
              "integer",
              "null"
            ],
            "description": "The job's `processed_emails` — how many addresses it finished — which is what the export was counted against. `null` when the job row carries none."
          },
          "rows_missing": {
            "type": [
              "integer",
              "null"
            ],
            "description": "`expected_rows` minus the rows this read returned, or `0` when nothing is missing. `null` when completeness was not verified. Never negative: a read returning more than `processed_emails` is not short."
          },
          "message": {
            "type": "string",
            "description": "Present only when `complete` is not `true`. Says what is missing and what to do — normally request the download again in a few seconds, because the index catches up. A persistent shortfall means those rows aged out of the plan's data-retention window."
          },
          "expires_in_seconds": {
            "type": "integer"
          }
        },
        "required": [
          "download_url",
          "format",
          "result_count",
          "expires_in_seconds",
          "complete",
          "expected_rows",
          "rows_missing"
        ]
      },
      "DownloadedResult": {
        "type": "object",
        "description": "One row of a `format=json` download. A FLAT shape, produced by the exporter rather than the verification API: there is no `quality_score`, no `flags` object and no `verification` object here.",
        "properties": {
          "email": {
            "type": "string"
          },
          "status": {
            "type": "string"
          },
          "score": {
            "type": "number",
            "description": "The quality score, named `score` in this shape."
          },
          "disposable": {
            "type": "boolean"
          },
          "role": {
            "type": "boolean"
          },
          "free_provider": {
            "type": "boolean"
          },
          "catch_all": {
            "type": "boolean"
          },
          "suggestion": {
            "type": [
              "string",
              "null"
            ]
          },
          "mx_records": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "smtp_response": {
            "type": [
              "string",
              "null"
            ]
          },
          "verified_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        },
        "required": [
          "email",
          "status",
          "score"
        ]
      },
      "JobListItem": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Job"
          }
        ],
        "type": "object",
        "description": "A row of the job listing: a `Job` plus the field that distinguishes an uploaded-file job.",
        "properties": {
          "filename": {
            "type": [
              "string",
              "null"
            ],
            "description": "The uploaded file this job came from, or null for a job submitted as a list."
          }
        }
      },
      "SubAccount": {
        "type": "object",
        "description": "What `GET /v1/account` returns when the key belongs to a white-label sub-account. A sub-account has no plan, no allowance and no keys of its own: it spends a pool its parent allocated, so the credit fields are `allocated` and `credits_used` rather than `monthly_allowance` and `used_this_month`. Their absence is the shape of this account, not missing billing data.",
        "properties": {
          "account": {
            "type": "object",
            "properties": {
              "user_id": {
                "type": "string",
                "description": "The sub-account's own id."
              },
              "email": {
                "type": "string",
                "format": "email"
              },
              "name": {
                "type": "string"
              },
              "plan": {
                "const": "sub_account",
                "description": "Always this. The parent's tier decides the limits; @see GET /v1/usage/limits, whose `plan_note` says where the inherited numbers come from."
              },
              "status": {
                "type": "string"
              },
              "created_at": {
                "description": "The sub-account creation time. Legacy rows that predate this field are returned as the empty string; the handler does not invent a timestamp.",
                "anyOf": [
                  {
                    "type": "string",
                    "format": "date-time"
                  },
                  {
                    "const": ""
                  }
                ]
              },
              "parent_user_id": {
                "type": "string",
                "description": "The account that provisioned this one and owns its credit pool."
              }
            },
            "required": [
              "user_id",
              "email",
              "plan",
              "status",
              "parent_user_id"
            ]
          },
          "credits": {
            "type": "object",
            "properties": {
              "balance": {
                "type": "number",
                "description": "What is left of the allocation. Fractional: a cached verification costs 0.5."
              },
              "allocated": {
                "type": "number",
                "description": "What the parent has granted in total. `0` for a sub-account that has never been allocated any."
              },
              "credits_used": {
                "type": "number",
                "description": "Spend since provisioning — exactly `allocated - balance`, which is why it is published even for a key without the analytics scope: both operands are already here. This says nothing about `usage.current_month`, which is still withheld and still flagged."
              }
            },
            "required": [
              "balance",
              "allocated"
            ]
          },
          "usage": {
            "type": "object",
            "description": "`current_month` and `period`, as on the parent shape."
          },
          "recent_jobs": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Job"
            }
          },
          "rate_limits": {
            "type": "object",
            "properties": {
              "requests_per_minute": {
                "type": "integer",
                "description": "The sub-account's own ceiling, inherited from the parent's tier."
              },
              "enforced": {
                "type": "boolean"
              }
            }
          },
          "partial": {
            "type": "object",
            "description": "Names sections that could NOT BE LOADED. Their values are placeholders, not facts. No `api_keys` here: this response carries no such section.",
            "properties": {
              "usage": {
                "const": true
              },
              "recent_jobs": {
                "const": true
              }
            }
          },
          "withheld": {
            "type": "object",
            "description": "Names sections this API key's SCOPES DO NOT COVER. `usage: true` means `usage.current_month` is ZERO-FILLED rather than measured. `api_keys: true` is always set here, because a sub-account has no key list to read.",
            "properties": {
              "usage": {
                "const": true
              },
              "api_keys": {
                "const": true
              },
              "recent_jobs": {
                "const": true
              }
            }
          }
        },
        "required": [
          "account",
          "credits"
        ]
      }
    },
    "responses": {
      "BadRequest": {
        "description": "The request is malformed, or names a field this endpoint does not accept. Retrying it unchanged will fail identically.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "No credential, or one that is not accepted. `code` distinguishes `API_KEY_SUSPENDED` (subscription ended — restartable) from `API_KEY_REVOKED`, `SESSION_EXPIRED` and `OAUTH_TOKEN_EXPIRED` (a connected app's one-hour access token lapsed — refresh it and retry, or sign in again if no refresh token was issued or the refresh is refused).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      },
      "Forbidden": {
        "description": "Refused, and **the code says which of three problems it is** — they need different remedies:\n\n- `INSUFFICIENT_SCOPE`: the credential is valid but not scoped for this route. With an API key, issue one that carries the scope named in the message. With an OAuth access token, connect the app again asking for that scope as well as the ones it has — unless the message says a connected app can never be given it; then use an API key with every scope the message names, or have the account owner do it in the dashboard.\n- `PLAN_UPGRADE_REQUIRED`: the account's plan does not include the feature. **No key of any scope reaches it** — issuing another is wasted work; the remedy is a plan change. Returned by the verification routes for `options.enrich`, and by the finder, the monitors, the audit log, auto-clean and the white-label routes.\n- `PLAN_LIMIT_EXCEEDED`: the plan includes the feature and the account is at its ceiling (webhook endpoints, team seats, integrations, auto-clean lists). Delete one or upgrade.\n- `FORBIDDEN`: refused for a reason none of the above covers.\n\n`ErrorCode` is an open set, so branch on the code with a default rather than exhaustively.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      },
      "NotFound": {
        "description": "No such resource on this account.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      },
      "InsufficientCredits": {
        "description": "The account cannot pay for this request. The message carries the numbers and where to top up. Nothing was charged.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      },
      "IdempotencyConflict": {
        "description": "The `Idempotency-Key` was used before for a different request on this route. No credit moved. Repeat the original request exactly, or use a new key.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      },
      "PayloadTooLarge": {
        "description": "The request body is over the limit.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      },
      "RateLimited": {
        "description": "Too many requests. When the server knows how long to wait, `Retry-After` says so and `error.retry_after_seconds` carries the same number; the `X-RateLimit-*` headers say where you stand.",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          },
          "X-RateLimit-Limit": {
            "$ref": "#/components/headers/RateLimitLimit"
          },
          "X-RateLimit-Remaining": {
            "$ref": "#/components/headers/RateLimitRemaining"
          },
          "X-RateLimit-Reset": {
            "$ref": "#/components/headers/RateLimitReset"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      },
      "ServiceUnavailable": {
        "description": "Temporarily unable to serve; `error.retryable` says whether waiting cures it. On a credit-charging route a contended balance answers this too, not an empty wallet: that one charged nothing, so retry.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      }
    }
  }
}
