---
title: "Look at a domain"
method: GET
path: "/v1/domain/{domain}"
tags: ["Search"]
---

# Look at a domain

`GET /v1/domain/{domain}`

## Request

- Base URL: `https://api.kawaa.com`
- URL: `https://api.kawaa.com/v1/domain/{domain}`
- Auth: one of:
  - API key in header `X-Api-Key`
  - HTTP bearer
  - OAuth 2 (authorization code, authorization URL `https://api.kawaa.com/oauth/authorize`, token URL `https://api.kawaa.com/oauth/token`, scopes: `verification:write`)

Which common addresses at a domain its mail server accepted (or reported full), and whether the domain accepts mail for every address. The domain's mail servers are read to decide whether a search can start, but they are not returned.

**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.

Repeat the same GET to poll. Each poll assembles whatever this search's own checks have finished (results other checks left in the shared cache are not used, and an address is listed only when a mail server accepted it or reported its mailbox full); when every candidate has a definite answer, the domain turns out to be catch-all, or the job ages out (about 30 seconds), it returns `status: "completed"`. A complete search is cached for 7 days. A candidate counts as checked only on a definite answer: a found mailbox, or a definite no (`invalid`, `disposable`, `spam_trap`). An answer that settles nothing (`unknown`, `risky` without mailbox evidence, a `policy_block`, `greylisted`, `temporary_failure`, `connection_failure` or `dns_only` result, or no answer at all) does not, so a search where some candidates got no definite answer ends as `completion_reason: "time_limit_reached"` with `patterns_checked` below the total. That partial result is kept for 15 minutes, not 7 days; the next search after that runs (and charges) afresh. **The poll that completes a partial search refunds the unchecked share of the 10 credits** to the account that started it, in half credits rounded in the customer's favour (nothing checked refunds all 10), and reports it as `credits_refunded`. **Polling never charges again** (the owner paid at the first call), and a search served from that 7-day cache charges nothing at all: it answers `credits_used: 0` for the owner and for anyone else.

**A domain that cannot receive mail is answered before anything is charged.** Before a new search is started, the domain's mail servers are read the way the verifier reads them: an MX record with a real host, or, with no MX at all, the domain's own A or AAAA address (RFC 5321's implicit MX). A domain that does not exist, publishes a null MX (RFC 7505), or has neither an MX nor an address is a `400` saying which, with nothing searched and no credits charged: no mailbox can exist there. Such a search used to be queued and billed 10 credits for 46 answers known in advance. A lookup that fails (no answer, SERVFAIL) is a `503`, also uncharged; retry it. A domain under a special-use top-level name (`.example`, `.test`, `.invalid`, `.localhost`, `.local`, `.internal` and the other special-use names listed under `POST /v1/find`) is a `400` before anything is read.

**A public mailbox provider is a 400 before anything is charged.** gmail.com, yahoo.com, outlook.com and the other domains `POST /v1/verify` reports `free_provider: true` for hold mailboxes of unrelated individuals, not one organization, so their common addresses would only list strangers' mailboxes. A cached result for one is not served; a search already running for one still finishes for the account that paid for it.

**Searches that are not paid for in full are limited to 20 per account per UTC day.** A search counts from the moment it starts until its result is collected. One that completes with every mailbox answered, or as a catch-all domain, keeps its charge and stops counting; one that ends `time_limit_reached` (and is refunded the unchecked share) counts for the rest of the day, and so does one nobody has polled to completion yet. At the limit a new search is a `429` with `retry_after_seconds` (and `Retry-After`) up to midnight UTC, and nothing is claimed, queued or charged. Polling a search, and a domain served from the cache, never count and are never refused by it. Without it an account could start search after refunded search at no cost while each one still ran its 46 checks against mail servers it picked.

Treating the first response as the finished result is the mistake this endpoint invites: you have paid for a search and read none of it.

## Path parameters

- `domain` string, required

## Response `200`

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.

- object
  - `success` unknown, required
  - `data` object, required
    - `domain` string, required: The domain as normalized: lower-cased, one leading `www.` stripped.
    - `status` 'processing' | 'completed', required: `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` object[], required: 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, so read `catch_all_domain` before concluding there are none.
      - `email` string, email, required
      - `status` 'valid' | 'invalid' | 'risky' | 'unknown' | 'catch_all' | 'disposable' | 'role' | 'spam_trap', required: 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.
      - `quality_score` integer, required: 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` boolean, required: 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` boolean, required: 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` boolean, required: 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.
    - `email_count` integer, required: `emails.length`, for a client that only needs the count.
    - `job_id` string: The search this poll belongs to. Present on the polls of the account that started the search while work is in flight. Another account polling the same domain gets the progress only, without the job id or any addresses.
    - `progress` object: How far the search has got. `percent` is capped at 99 until the search completes, so 100 only ever means finished.
      - `checked` integer
      - `total` integer
      - `percent` integer
    - `progress_percent` integer: The same number as `progress.percent`, flattened.
    - `patterns_checked` integer: Candidate addresses with a definite answer: a found mailbox or a definite no. An inconclusive answer, or none, is not counted. Present once the search completes.
    - `completion_reason` 'all_candidates_checked' | 'time_limit_reached' | 'catch_all_domain': Why the search stopped. `time_limit_reached` means the answer is partial: some candidates got no definite answer (no reply in time, deferred, refused, or a policy block of our sending address), so fewer were checked than exist. A partial result is kept for 15 minutes rather than 7 days, and its unchecked share of the charge is refunded. `catch_all_domain` means no individual address could be confirmed at all.
    - `catch_all_domain` boolean: True when the domain accepts every recipient, so an empty `emails` says nothing about which addresses exist.
    - `from_cache` boolean: True when this came from the completed cache: 7 days for a complete search, 15 minutes for a partial one (`time_limit_reached`). Free, for the owner and for anyone else.
    - `credits_used` number, required: 10 on the call that starts the search, 0 on every poll and on a cache hit. Read it rather than assuming.
    - `credits_refunded` number: Present only on the poll, by the account that started the search, that completes a partial search (`time_limit_reached`) it paid for: the credits given back for the candidates it could not check, in half credits. `credits_remaining` already includes them. A later read of the cached result does not repeat it.
    - `credits_remaining` number
    - `message` string: Prose for the state above, including why a search stopped early.
    - `cached_at` string, date-time: 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.

## Other responses

- `400`: The domain cannot be searched, and nothing was charged. `Invalid domain format` (see the `domain` parameter); a special-use name the public DNS never answers for (such as `.example`, `.test` or `.local`); or a domain that cannot receive mail, so no mailbox can exist there: it does not exist in DNS, it publishes a null MX (RFC 7505), or it has no MX record and no A or AAAA address to deliver to; or a public mailbox provider such as gmail.com, whose mailboxes belong to unrelated individuals. The message says which. Retrying it unchanged will fail identically until the domain's DNS changes.
- `401`: 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).
- `402`: The account cannot pay for this request. The message carries the numbers and where to top up. Nothing was charged.
- `403`: Refused, and **the code says which of three problems it is**, and they need different remedies: - `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. - `PLAN_UPGRADE_REQUIRED`: the account's plan does not include the feature. **No key of any scope reaches it**, so 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. - `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. - `FORBIDDEN`: refused for a reason none of the above covers. `ErrorCode` is an open set, so branch on the code with a default rather than exhaustively.
- `429`: Too many requests, or this account's daily limit on searches that are not paid for in full: 20 per UTC day that ended `time_limit_reached` (refunded) or have not been polled to completion yet. Neither starts, queues or charges anything. The limit's message says so, and `retry_after_seconds` (with `Retry-After`) is the time to midnight UTC. A search still running stops counting once a poll completes it with every mailbox answered, or as a catch-all domain, so repeat the GET for those to collect them. Domains in the cache are still served, free.
- `500`: The search could not be started, or it failed in a way the API could not classify. Four failures answer this: reserving the search, queueing its candidates (none were queued), a credit deduction that failed, and any other unexpected error. **The envelope says when nothing was charged.** Reserving and queueing come before the debit. A deduction that failed is settled before answering: if it was charged after all, the search is kept and answered `200` with `status: "processing"` instead of this error, and if it was not, the search is dropped, nothing can charge for it any more, and the message says `No credits were charged`. None of these carries `retry_may_charge_again`. When the deduction's outcome could not be established, or the failure was an unexpected one, the answer is ambiguous on money and the envelope carries `retry_may_charge_again: true`: check `GET /v1/usage/credits/transactions` before searching the same domain again. Calling the same GET again is still the cheaper first move: a search still in flight is polled free, and a domain in the 7-day completed cache is served free (look at `from_cache`).
- `503`: The search could not start or could not be resumed: the cache read was unavailable, the domain's mail servers could not be looked up, the account's daily search limit could not be read, a legacy search could not be refreshed, or the credit debit hit transient contention. These fail BEFORE the charge, so 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.
- `default`: Any status this operation does not list. Kawaa's own errors carry the error envelope. The gateway in front of the API can answer any operation with its own bare body (`GatewayError`): a `503` after about 29 seconds, when the request may still have run and been charged, a `413` for a body over about 6 MB, or a `400` for a path with malformed percent-encoding.

---

[API](https://skmtc.dev/kawaa/apis/kawaa-api.md) · [All operations](https://skmtc.dev/kawaa/apis/kawaa-api/llms.txt) · [OpenAPI document](https://skmtc.dev/kawaa/apis/kawaa-api/revisions/d27c9c33f6ba?raw)
