---
title: "Find business addresses for several people"
method: POST
path: "/v1/find/bulk"
tags: ["Search"]
---

# Find business addresses for several people

`POST /v1/find/bulk`

## Request

- Base URL: `https://api.kawaa.com`
- URL: `https://api.kawaa.com/v1/find/bulk`
- 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`)

The same lookup for several people at one domain. Needs the Starter plan or higher, like `POST /v1/find`: on the Free plan it is refused with `403 PLAN_UPGRADE_REQUIRED` before anything is charged. 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.

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

"Usable" is the same rule as `POST /v1/find`: both names must contain at least one letter after normalization (spelled in a-z: accents dropped, `ø` as `o`, `ß` as `ss` and so on, then only `a-z` kept), and no letter without a single a-z spelling. A person who fails it comes back as an `invalid_name` row, with a `message` saying why, 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.

**A special-use domain is a 400 before the charge.** `.example`, `.test`, `.invalid`, `.localhost`, `.local`, `.onion`, `.alt`, `.internal`, `.corp`, `.home`, `.mail`, and `home.arpa` with the other special-use `.arpa` names are never answered for in the public DNS, so no address under them can exist and every candidate could only fail. So are RFC 2606's documentation domains `example.com`, `example.net` and `example.org` and any name under them: they resolve, but publish a null MX, and the worker refuses them before SMTP. They used to be searched and billed (5 credits per usable name) for a `not_found` that was certain before the search began. A name over 64 characters, or one that spells to more than 64 letters, is a 400 for the whole request.

**A domain that cannot receive mail, or a public mailbox provider, is a 400 for the whole request before the charge**, exactly as on `POST /v1/find`, and a failed mail-server lookup is an uncharged `503`. An earlier attempt of the same request that was already charged, replayed or recorded is still answered from its retry protection instead.

**A person whose probes settled nothing is `not_checked` and refunded**: a probe that is `unknown`, a policy block, greylisting, a temporary failure or `risky` without evidence that the mailbox exists is never that person's address.

**Only a row with `verified: true` is a confirmed address**, exactly as on `POST /v1/find`. A row with an `email` and `verified: false` is a best guess, and `found` counts it; `verified_found` counts only the confirmed rows.

## Headers

- `Idempotency-Key` string

## Request body

- FindBulkRequest
  - `domain` string, required: The company's mail domain, as a bare name. A special-use name (`.example`, `.test`, `.invalid`, `.localhost`, `.local`, `.onion`, `.alt`, `.internal`, `.corp`, `.home`, `.mail`, `home.arpa` and the other special-use `.arpa` names) and RFC 2606's `example.com` / `.net` / `.org` with any name under them are a 400 before the charge: no mailbox can exist there.
  - `people` object[], required: Up to 50 people per request; more is a 400 before any lookup or charge.
    - `first_name` string, required
    - `last_name` string, required

## Response `200`

The searches that were run. **Not necessarily one result per person**: when the request approaches its time limit the API 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.

- object
  - `success` unknown, required
  - `data` object, required
    - `domain` string, required: The domain the searches ran against, normalized (lower-cased, one leading `www.` stripped).
    - `pattern` string, nullable: The address pattern this run's confirmed rows (`verified: true`) use, the most common one among them, or `null` when the run confirmed none. It is not a pattern remembered from earlier searches or the pattern of a best guess. On a replay it is the original run's value.
    - `results` object[], required: 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.
      - `first_name` string, required
      - `last_name` string, required
      - `email` string, nullable, required
      - `confidence` number, required
      - `pattern` string, nullable, required: The pattern `email` was built from, e.g. `first.last`. It says something about the domain only when `verified` is true. On a best guess (`verified: false`) it is just the pattern of the candidate the finder ranked highest, not one the domain was seen to use. `null` when there is no `email`.
      - `verified` boolean, required: As on `POST /v1/find`: true only on a `valid` verdict. A row with an `email` and `verified: false` is a best guess, not an address confirmed as this person's.
      - `status` string, required: `catch_all` is inconclusive rather than negative: the domain accepts every recipient, so nothing could be confirmed either way. `not_checked` means the search produced no usable candidate and did not receive a definite answer for every generated address (a probe that timed out, or one that settled nothing: `unknown`, a policy block, greylisting, a temporary failure, `risky` without mailbox evidence). Some candidates may have been rejected; the remaining addresses are unmeasured, so no negative conclusion can be drawn about the person. This person is refunded. `invalid_name` means no candidate could be generated from the name; its `message` says why, and the person is not charged.
      - `message` string
    - `total` integer, required: People in the request.
    - `processed` integer, required: People actually searched for. Below `total` means the run stopped early and the rest were refunded. `not_checked` people count as processed, and are refunded too.
    - `found` integer, required: Rows that carry an address, best guesses (`verified: false`) included. Not a count of confirmed addresses: count the rows with `verified: true` for that.
    - `verified_found` integer: Rows whose address a mail server confirmed (`verified: true`). Never more than `found`. Absent only on a replay (`replayed: true`) of a summary stored before this field existed: count nothing in that case rather than reading it as zero.
    - `credits_used` number, required: What was charged after any refund for an unprocessed tail or for `not_checked` people, 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` number
    - `replayed` boolean: 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` boolean: False on a replay, where only the summary was kept.
    - `message` string

## Other responses

- `400`: The request cannot be searched, and nothing was claimed or charged. A malformed request, a field this endpoint does not accept, more than 50 people, or a person whose name is longer than 64 characters or letters once spelled; a special-use or RFC 2606 domain; a domain that cannot receive mail (it does not exist in DNS, publishes a null MX, or has no MX record and no A or AAAA address); or a public mailbox provider. The message says which. Retrying it unchanged will fail identically.
- `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.
- `409`: A matching request is already using this retry key or is being reset. Wait and retry the identical request: it is the retry protection working, not a rejection of the payload. Or, when an `Idempotency-Key` was sent: the same key was used for a different request (`IDEMPOTENCY_KEY_CONFLICT`). Repeat the original request exactly, or choose a new key. Nothing was charged and nothing was started in either case. One more `409 CONFLICT` is final (`retryable: false`): a matching request failed after it was charged and its refund could not be confirmed. That charge may stand: look for its refund in `GET /v1/usage/credits/transactions`, and ask support to reconcile it if there is none. The identical request is refused for a day after the failure (the message gives the time); after that it runs, and is charged, as a new search.
- `429`: 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.
- `500`: 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.
- `503`: The domain's mail servers could not be looked up (nothing was claimed or charged; retry after `retry_after_seconds`), 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. It also answers a search in which every chargeable person had an incomplete candidate search and no usable address was established, including partial rejected-candidate evidence. After the whole charge is refunded, nothing was charged and a retry of the identical request runs a new search.
- `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)
